canopus 0.4.0 → 0.5.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.
Files changed (137) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +191 -19
  4. data/assets/keymaps/emacs.jsonc +5 -0
  5. data/assets/keymaps/jetbrains.jsonc +5 -0
  6. data/assets/keymaps/sublime.jsonc +5 -0
  7. data/assets/keymaps/vscode.jsonc +5 -0
  8. data/docs/debugging.md +143 -0
  9. data/docs/distribution.md +40 -9
  10. data/docs/lsp.md +131 -0
  11. data/docs/performance.md +18 -0
  12. data/docs/providers.md +36 -0
  13. data/docs/release-log.md +12 -0
  14. data/docs/tasks.md +64 -0
  15. data/docs/testing.md +49 -0
  16. data/docs/vim.md +3 -0
  17. data/exe/canopus +4 -1
  18. data/lib/canopus/buffer.rb +291 -50
  19. data/lib/canopus/cli.rb +5 -2
  20. data/lib/canopus/command.rb +13 -4
  21. data/lib/canopus/controller.rb +183 -40
  22. data/lib/canopus/debug/breakpoints.rb +550 -0
  23. data/lib/canopus/debug/configuration.rb +291 -0
  24. data/lib/canopus/debug/console.rb +243 -0
  25. data/lib/canopus/debug/panel.rb +487 -0
  26. data/lib/canopus/debug/session.rb +354 -0
  27. data/lib/canopus/decoration.rb +4 -2
  28. data/lib/canopus/denebola_compat.rb +27 -0
  29. data/lib/canopus/diagnostics.rb +173 -0
  30. data/lib/canopus/display_map/overlay_map.rb +6 -2
  31. data/lib/canopus/editor/snippet_expandable.rb +3 -2
  32. data/lib/canopus/editor.rb +122 -31
  33. data/lib/canopus/git/state.rb +100 -0
  34. data/lib/canopus/language/background_analysis.rb +93 -14
  35. data/lib/canopus/language/document.rb +70 -34
  36. data/lib/canopus/language/symbol.rb +1 -0
  37. data/lib/canopus/language/syntax_worker.rb +61 -9
  38. data/lib/canopus/language.rb +3 -0
  39. data/lib/canopus/minimap.rb +201 -0
  40. data/lib/canopus/patch.rb +3 -1
  41. data/lib/canopus/plugins/api.rb +4 -2
  42. data/lib/canopus/plugins/isolated_runtime.rb +64 -7
  43. data/lib/canopus/plugins/registry.rb +114 -3
  44. data/lib/canopus/plugins.rb +8 -7
  45. data/lib/canopus/project.rb +15 -6
  46. data/lib/canopus/provider.rb +192 -0
  47. data/lib/canopus/regexp_compat.rb +36 -4
  48. data/lib/canopus/selection.rb +12 -0
  49. data/lib/canopus/settings/schema.rb +201 -0
  50. data/lib/canopus/settings.rb +328 -49
  51. data/lib/canopus/task/configuration.rb +356 -0
  52. data/lib/canopus/task/problem_matcher.rb +264 -0
  53. data/lib/canopus/task/runner.rb +314 -0
  54. data/lib/canopus/test_runner/bounded_ignore.rb +144 -0
  55. data/lib/canopus/test_runner/discovery.rb +97 -0
  56. data/lib/canopus/test_runner/execution.rb +194 -0
  57. data/lib/canopus/test_runner/minitest.rb +116 -0
  58. data/lib/canopus/test_runner/rspec.rb +103 -0
  59. data/lib/canopus/test_runner/safe_walker.rb +37 -0
  60. data/lib/canopus/test_runner.rb +54 -0
  61. data/lib/canopus/theme/import.rb +131 -0
  62. data/lib/canopus/theme.rb +28 -4
  63. data/lib/canopus/version.rb +1 -1
  64. data/lib/canopus/vim/motionable.rb +1 -1
  65. data/lib/canopus/vim.rb +10 -16
  66. data/lib/canopus/workspace/auto_savable.rb +83 -0
  67. data/lib/canopus/workspace/debug_aware.rb +600 -0
  68. data/lib/canopus/workspace/edit/plan.rb +2 -2
  69. data/lib/canopus/workspace/editor_configurable.rb +113 -0
  70. data/lib/canopus/workspace/file_change_aware.rb +11 -3
  71. data/lib/canopus/workspace/git_aware.rb +80 -34
  72. data/lib/canopus/workspace/git_blame.rb +175 -0
  73. data/lib/canopus/workspace/git_conflict_resolution.rb +410 -0
  74. data/lib/canopus/workspace/git_diff_view.rb +641 -0
  75. data/lib/canopus/workspace/git_history.rb +171 -0
  76. data/lib/canopus/workspace/git_remote.rb +267 -0
  77. data/lib/canopus/workspace/git_staging.rb +583 -0
  78. data/lib/canopus/workspace/hierarchy_aware.rb +477 -0
  79. data/lib/canopus/workspace/keymap_aware.rb +66 -0
  80. data/lib/canopus/workspace/language_aware.rb +3721 -135
  81. data/lib/canopus/workspace/language_server_configurable.rb +287 -71
  82. data/lib/canopus/workspace/persistent_undo.rb +169 -0
  83. data/lib/canopus/workspace/problems_aware.rb +131 -0
  84. data/lib/canopus/workspace/project_searchable.rb +88 -11
  85. data/lib/canopus/workspace/project_tree_editable.rb +3 -1
  86. data/lib/canopus/workspace/recoverable.rb +204 -0
  87. data/lib/canopus/workspace/session_persistable.rb +85 -13
  88. data/lib/canopus/workspace/settings_aware.rb +76 -2
  89. data/lib/canopus/workspace/task_aware.rb +194 -0
  90. data/lib/canopus/workspace/test_aware.rb +394 -0
  91. data/lib/canopus/workspace/trust.rb +73 -0
  92. data/lib/canopus/workspace/view/terminal_presentable.rb +298 -47
  93. data/lib/canopus/workspace/view.rb +399 -74
  94. data/lib/canopus/workspace.rb +688 -44
  95. data/lib/canopus.rb +20 -2
  96. data/sig/canopus.rbs +138 -31
  97. data/sig/controller.rbs +10 -3
  98. data/sig/debug.rbs +121 -0
  99. data/sig/decoration.rbs +12 -2
  100. data/sig/diagnostics.rbs +26 -0
  101. data/sig/minimap.rbs +20 -0
  102. data/sig/provider.rbs +29 -0
  103. data/sig/services.rbs +1 -16
  104. data/sig/task.rbs +92 -0
  105. data/sig/test_runner.rbs +109 -0
  106. data/sig/trust.rbs +17 -0
  107. data/sig/workspace_edits.rbs +2 -2
  108. data/sig/workspace_services.rbs +234 -2
  109. data/tools/check_dependencies.rb +21 -2
  110. data/tools/package.rb +172 -14
  111. data/tools/package_signature_test.rb +33 -0
  112. data/tools/package_test.rb +29 -0
  113. data/tools/sign_package.rb +123 -0
  114. data/tools/update.rb +189 -0
  115. data/tools/update_test.rb +71 -0
  116. metadata +206 -31
  117. data/lib/canopus/lsp/client.rb +0 -307
  118. data/lib/canopus/lsp/error.rb +0 -7
  119. data/lib/canopus/lsp/future/subscription.rb +0 -9
  120. data/lib/canopus/lsp/future.rb +0 -87
  121. data/lib/canopus/lsp/protocol.rb +0 -83
  122. data/lib/canopus/lsp/server_error.rb +0 -13
  123. data/lib/canopus/lsp/timeout.rb +0 -7
  124. data/lib/canopus/lsp/transport.rb +0 -123
  125. data/lib/canopus/lsp.rb +0 -19
  126. data/lib/canopus/project/search.rb +0 -164
  127. data/lib/canopus/project/search_worker/cancelled.rb +0 -3
  128. data/lib/canopus/project/search_worker/runner.rb +0 -9
  129. data/lib/canopus/project/search_worker.rb +0 -108
  130. data/lib/canopus/terminal/cell.rb +0 -7
  131. data/lib/canopus/terminal/grid.rb +0 -321
  132. data/lib/canopus/terminal/pty.rb +0 -178
  133. data/lib/canopus/terminal/scrollback.rb +0 -38
  134. data/lib/canopus/terminal/vt.rb +0 -398
  135. data/lib/canopus/terminal.rb +0 -13
  136. data/sig/lsp.rbs +0 -99
  137. data/sig/terminal.rbs +0 -130
@@ -0,0 +1,354 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "tempfile"
5
+ require_relative "../error"
6
+
7
+ module Canopus
8
+ module Debug
9
+ class Session
10
+ DEFAULT_TIMEOUT = 10
11
+ CONNECT_INTERVAL = 0.05
12
+
13
+ attr_reader :client
14
+
15
+ def initialize(root:, configuration:, adapter:, breakpoints:, client: nil, timeout: DEFAULT_TIMEOUT)
16
+ @root = File.realpath(root)
17
+ @configuration = configuration
18
+ @adapter = adapter
19
+ @breakpoints = breakpoints
20
+ @client = client
21
+ @timeout = timeout
22
+ unless timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?
23
+ raise ArgumentError, "debug session timeout must be positive and finite"
24
+ end
25
+ @handlers = {}
26
+ @lock = Mutex.new
27
+ @closed = false
28
+ rescue SystemCallError, TypeError => error
29
+ raise Error, "invalid debug session: #{error.message}"
30
+ end
31
+
32
+ def on(event, &handler)
33
+ raise ArgumentError, "debug event handler required" unless handler
34
+
35
+ @lock.synchronize { (@handlers[event.to_sym] ||= []) << handler }
36
+ handler
37
+ end
38
+
39
+ def stopped_thread_id = @lock.synchronize { @stopped_thread_id }
40
+
41
+ def stack_frames(levels: 100)
42
+ client, thread_id = inspection_state
43
+ client.stack_trace(thread_id, start: 0, levels: levels)
44
+ end
45
+
46
+ def scopes(frame_id) = inspection_client.scopes(frame_id)
47
+ def variables(reference) = inspection_client.variables(reference)
48
+ def evaluate(expression, frame_id:, context: "watch") = inspection_client.evaluate(expression, frame_id: frame_id, context: context)
49
+
50
+ def start
51
+ deadline = monotonic_time + @timeout
52
+ initialized = Queue.new
53
+ client = @client || build_client(deadline)
54
+ install_client(client)
55
+ client.on(:initialized) { initialized << true }
56
+ client.on(:stopped) { |event| stopped(client, event) }
57
+ client.on(:continued) { clear_stopped_thread; emit(:continued) }
58
+ client.on(:output) { |event| emit(:output, event) }
59
+ client.on(:terminated) { clear_stopped_thread; emit(:terminated) }
60
+ client.on(:exited) { clear_stopped_thread; emit(:terminated) }
61
+
62
+ capabilities = client.start(adapter_id: adapter_id, timeout: remaining(deadline))
63
+ request = request_start(client)
64
+ wait_for_initialized(initialized, request, deadline)
65
+ set_breakpoints(client, capabilities, deadline)
66
+ if capabilities["supportsConfigurationDoneRequest"]
67
+ client.configuration_done.await(timeout: remaining(deadline))
68
+ end
69
+ request.await(timeout: remaining(deadline))
70
+ self
71
+ rescue StandardError => error
72
+ begin
73
+ request.await(timeout: 0) if request&.done?
74
+ rescue StandardError => start_error
75
+ error = start_error
76
+ end
77
+ begin
78
+ close
79
+ rescue StandardError
80
+ nil
81
+ end
82
+ raise debug_error(error)
83
+ end
84
+
85
+ def close
86
+ client, waiter, log = @lock.synchronize do
87
+ next if @closed
88
+
89
+ @closed = true
90
+ [@client, @process, @log]
91
+ end
92
+ return nil unless client || waiter || log
93
+
94
+ failure = nil
95
+ begin
96
+ client&.close
97
+ rescue StandardError => error
98
+ failure = error
99
+ ensure
100
+ begin
101
+ stop_process(waiter)
102
+ rescue StandardError => error
103
+ failure ||= error
104
+ ensure
105
+ begin
106
+ log&.close!
107
+ rescue StandardError => error
108
+ failure ||= error
109
+ end
110
+ end
111
+ end
112
+ raise debug_error(failure) if failure
113
+ nil
114
+ end
115
+
116
+ private
117
+
118
+ def inspection_state
119
+ @lock.synchronize do
120
+ raise Error, "debug session is not stopped" if @closed || !@client || !@stopped_thread_id
121
+
122
+ [@client, @stopped_thread_id]
123
+ end
124
+ end
125
+
126
+ def inspection_client = inspection_state.first
127
+
128
+ def clear_stopped_thread = @lock.synchronize { @stopped_thread_id = nil }
129
+
130
+ def build_client(deadline)
131
+ command = @adapter.fetch("command")
132
+ case @adapter.fetch("transport")
133
+ when "stdio"
134
+ Megrez::Session.stdio(command: command, env: debug_environment,
135
+ cwd: @configuration["cwd"] || @root)
136
+ when "tcp"
137
+ build_tcp_client(command, deadline)
138
+ else
139
+ raise Error, "unsupported debug adapter transport"
140
+ end
141
+ end
142
+
143
+ def build_tcp_client(command, deadline)
144
+ unless File.basename(command.first) == "rdbg"
145
+ raise Error, "tcp debug adapters currently require rdbg"
146
+ end
147
+ raise Error, "rdbg tcp adapters only support launch" unless @configuration["request"] == "launch"
148
+
149
+ host, port = "127.0.0.1", available_port
150
+ arguments = command.map { |argument| argument == "--open" ? "--open=vscode" : argument }
151
+ arguments << "--open=vscode" unless arguments.any? { |argument| argument.start_with?("--open=") }
152
+ arguments.concat(["--host", host, "--port", port.to_s])
153
+ if @configuration["request"] == "launch" && @configuration["program"]
154
+ arguments.concat(["--", @configuration.fetch("program"), *@configuration.fetch("args", [])])
155
+ end
156
+ log = Tempfile.new(["canopus-debug-", ".log"])
157
+ pid = Process.spawn(debug_environment, *arguments, chdir: @configuration["cwd"] || @root,
158
+ out: log, err: log)
159
+ waiter = Process.detach(pid)
160
+ rejected = @lock.synchronize do
161
+ if @closed
162
+ true
163
+ else
164
+ @log, @process = log, waiter
165
+ false
166
+ end
167
+ end
168
+ if rejected
169
+ stop_process(waiter)
170
+ log.close!
171
+ raise Error, "debug session is closed"
172
+ end
173
+ connect_tcp(host, port, deadline)
174
+ rescue SystemCallError => error
175
+ log&.close! unless defined?(@log) && @log.equal?(log)
176
+ raise Error, "debug adapter spawn failed: #{error.message}"
177
+ end
178
+
179
+ def connect_tcp(host, port, deadline)
180
+ loop do
181
+ raise Error, "debug session is closed" if closed?
182
+ if @process&.join(0)
183
+ raise Error, "debug adapter exited before accepting DAP#{adapter_log}"
184
+ end
185
+ begin
186
+ return Megrez::Session.tcp(host: host, port: port,
187
+ connect_timeout: [remaining(deadline), 0.2].min)
188
+ rescue Megrez::Error
189
+ raise if monotonic_time >= deadline
190
+
191
+ sleep([CONNECT_INTERVAL, remaining(deadline)].min)
192
+ end
193
+ end
194
+ end
195
+
196
+ def install_client(client)
197
+ rejected = @lock.synchronize do
198
+ if @closed
199
+ true
200
+ else
201
+ @client = client
202
+ false
203
+ end
204
+ end
205
+ if rejected
206
+ client.close
207
+ raise Error, "debug session is closed"
208
+ end
209
+ end
210
+
211
+ def request_start(client)
212
+ arguments = @configuration.reject { |key, _| %w[name type request].include?(key) }
213
+ if @adapter["transport"] == "tcp" && File.basename(@adapter.fetch("command").first) == "rdbg"
214
+ arguments = arguments.reject { |key, _| %w[program args cwd env].include?(key) }.merge("localfs" => true)
215
+ end
216
+ @configuration.fetch("request") == "launch" ? client.launch(arguments) : client.attach(arguments)
217
+ end
218
+
219
+ def wait_for_initialized(queue, request, deadline)
220
+ loop do
221
+ initialized = queue.pop(true)
222
+ request.await(timeout: 0) if request.done?
223
+ return initialized
224
+ rescue ThreadError
225
+ request.await(timeout: 0) if request.done?
226
+ raise Error, "debug adapter did not initialize" if monotonic_time >= deadline
227
+ raise Error, "debug session is closed" if closed?
228
+
229
+ sleep([CONNECT_INTERVAL, remaining(deadline)].min)
230
+ end
231
+ end
232
+
233
+ def set_breakpoints(client, capabilities, deadline)
234
+ @breakpoints.entries.select(&:enabled).group_by(&:path).sort.each do |path, entries|
235
+ validate_breakpoint_capabilities!(entries, capabilities)
236
+ source = File.join(@root, path)
237
+ values = entries.map do |entry|
238
+ Megrez::SourceBreakpoint.new(line: entry.line, column: nil,
239
+ condition: capabilities["supportsConditionalBreakpoints"] ? entry.condition : nil,
240
+ hit_condition: capabilities["supportsHitConditionalBreakpoints"] ? entry.hit_condition : nil,
241
+ log_message: capabilities["supportsLogPoints"] ? entry.log_message : nil)
242
+ end
243
+ client.set_breakpoints(source, values).await(timeout: remaining(deadline))
244
+ end
245
+ end
246
+
247
+ def validate_breakpoint_capabilities!(entries, capabilities)
248
+ requirements = {
249
+ condition: "supportsConditionalBreakpoints",
250
+ hit_condition: "supportsHitConditionalBreakpoints",
251
+ log_message: "supportsLogPoints"
252
+ }
253
+ requirements.each do |field, capability|
254
+ if !capabilities[capability] && entries.any? { |entry| !entry.public_send(field).nil? }
255
+ raise Error, "debug adapter does not support #{field.to_s.tr('_', ' ')}"
256
+ end
257
+ end
258
+ end
259
+
260
+ def stopped(client, event)
261
+ thread_id = event["threadId"] || event[:threadId]
262
+ raise Error, "debug stop event has no thread" unless thread_id.is_a?(Integer) && thread_id.positive?
263
+
264
+ generation = client.generation
265
+ frame = client.stack_trace(thread_id, start: 0, levels: 1).await(timeout: @timeout).first
266
+ raise Error, "debug stop event has no stack frame" unless frame
267
+ return unless client.state == :stopped && client.generation == generation
268
+
269
+ accepted = @lock.synchronize do
270
+ next false if @closed || client.state != :stopped || client.generation != generation
271
+
272
+ @stopped_thread_id = thread_id
273
+ true
274
+ end
275
+ return unless accepted
276
+
277
+ emit(:stopped, frame)
278
+ rescue StandardError => error
279
+ return if generation && (client.state != :stopped || client.generation != generation)
280
+
281
+ emit(:error, debug_error(error))
282
+ end
283
+
284
+ def emit(event, value = nil)
285
+ handlers = @lock.synchronize { @closed ? [] : (@handlers[event] || []).dup }
286
+ handlers.each { |handler| handler.call(value) }
287
+ end
288
+
289
+ def debug_environment
290
+ value = @configuration.fetch("env", {})
291
+ valid = value.is_a?(Hash) && value.all? do |key, item|
292
+ key.is_a?(String) && (item.nil? || item.is_a?(String))
293
+ end
294
+ raise Error, "debug environment must contain string values" unless valid
295
+
296
+ ENV.each_key.grep(/\ABUNDLE/).to_h { |key| [key, nil] }
297
+ .merge("RUBYOPT" => nil, "RUBYLIB" => nil)
298
+ .merge(value)
299
+ end
300
+
301
+ def adapter_id
302
+ File.basename(@adapter.fetch("command").first) == "rdbg" ? "rdbg" : @configuration.fetch("type")
303
+ end
304
+
305
+ def available_port
306
+ server = TCPServer.new("127.0.0.1", 0)
307
+ server.local_address.ip_port
308
+ ensure
309
+ server&.close
310
+ end
311
+
312
+ def stop_process(waiter)
313
+ return unless waiter
314
+ return if waiter.join(0)
315
+
316
+ Process.kill(Gem.win_platform? ? "KILL" : "TERM", waiter.pid)
317
+ return if waiter.join(1)
318
+
319
+ return if Gem.win_platform?
320
+
321
+ Process.kill("KILL", waiter.pid)
322
+ waiter.join(1)
323
+ rescue Errno::ESRCH, Errno::ECHILD
324
+ nil
325
+ end
326
+
327
+ def adapter_log
328
+ return "" unless @log
329
+
330
+ @log.flush
331
+ text = File.binread(@log.path, 2_048).force_encoding(Encoding::UTF_8).scrub.strip
332
+ text.empty? ? "" : ": #{text}"
333
+ rescue IOError, SystemCallError
334
+ ""
335
+ end
336
+
337
+ def remaining(deadline)
338
+ value = deadline - monotonic_time
339
+ raise Megrez::Timeout, "debug session timed out" unless value.positive?
340
+
341
+ value
342
+ end
343
+
344
+ def closed? = @lock.synchronize { @closed }
345
+ def monotonic_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
346
+
347
+ def debug_error(error)
348
+ return error if error.is_a?(Error)
349
+
350
+ Error.new("debug session failed: #{error.message}")
351
+ end
352
+ end
353
+ end
354
+ end
@@ -39,7 +39,9 @@ module Canopus
39
39
  first, last = normalize_rows(row_range)
40
40
  state = [buffer.respond_to?(:version) ? buffer.version : nil,
41
41
  context&.object_id,
42
- context&.respond_to?(:selections) ? context.selections : buffer.respond_to?(:selections) ? buffer.selections : nil]
42
+ context&.respond_to?(:selections) ? context.selections : buffer.respond_to?(:selections) ? buffer.selections : nil,
43
+ context&.respond_to?(:tab_size) ? context.tab_size : nil,
44
+ context&.respond_to?(:language_document) ? context.language_document.object_id : nil]
43
45
  suppliers = @lock.synchronize do
44
46
  @suppliers.map { |source, supplier| [source, supplier, @generations[source]] }
45
47
  end
@@ -63,7 +65,7 @@ module Canopus
63
65
  end
64
66
  supplied
65
67
  end
66
- items.sort_by { |item| item.priority }.freeze
68
+ items.each_with_index.sort_by { |item, index| [item.priority, index] }.map!(&:first).freeze
67
69
  end
68
70
 
69
71
  def invalidate(source, buffer: nil, rows: nil)
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Canopus asks lazy ropes for a bounded line window while moving cursors. Keep
4
+ # this small compatibility shim until that helper is part of Denebola's API.
5
+ unless Denebola::LazyRope.method_defined?(:line_window)
6
+ class Denebola::LazyRope
7
+ def line_end(row)
8
+ start = line_start(row)
9
+ ending = row + 1 < line_count(exact: true) ? line_start(row + 1) : bytesize
10
+ tail_start = [ending - 3, start].max
11
+ tail = send(:read_bytes, tail_start, [ending - tail_start, 3].min)
12
+ ending - (tail.end_with?("\r\n") ? 2 : tail.end_with?("\r", "\n") ? 1 : 0)
13
+ end
14
+
15
+ def line_window(row, from: 0, max_bytes: 16_384)
16
+ start, ending = line_start(row), line_end(row)
17
+ offset = (start + from).clamp(start, ending)
18
+ offset -= 1 while offset > start && offset < ending && (send(:read_bytes, offset, 1).getbyte(0) & 0xc0) == 0x80
19
+ value = send(:read_bytes, offset, [max_bytes, ending - offset].min)
20
+ finish = value.bytesize
21
+ while finish.positive? && !value.byteslice(0, finish).force_encoding(Encoding::UTF_8).valid_encoding?
22
+ finish -= 1
23
+ end
24
+ [value.byteslice(0, finish).to_s.force_encoding(Encoding::UTF_8), offset - start]
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "sadr" unless defined?(Sadr::Protocol) && defined?(Sadr::Error)
5
+
6
+ module Canopus
7
+ module Diagnostics
8
+ SOURCES = %i[lsp settings task test].freeze
9
+ SEVERITIES = {error: 1, warning: 2, information: 3, hint: 4}.freeze
10
+ URI_LIMIT = 16_384
11
+ MESSAGE_LIMIT = 4_096
12
+ PUBLICATION_LIMIT = 10_000
13
+ TOTAL_LIMIT = 100_000
14
+ TOTAL_BYTES_LIMIT = 32 << 20
15
+ ITEM_BYTES_LIMIT = 1 << 20
16
+ VALUE_DEPTH_LIMIT = 100
17
+
18
+ Entry = Data.define(:source, :uri, :diagnostic)
19
+
20
+ class Registry
21
+ attr_reader :version
22
+
23
+ def initialize(&on_change)
24
+ @entries = {}
25
+ @sizes = {}
26
+ @counts = SEVERITIES.keys.to_h { |severity| [severity, 0] }
27
+ @total = @bytes = 0
28
+ @lock = Mutex.new
29
+ @on_change = on_change
30
+ @version = 0
31
+ end
32
+
33
+ def publish(source, uri, diagnostics, notify: true)
34
+ raise ArgumentError, "invalid diagnostics notification option" unless [true, false].include?(notify)
35
+ source = normalize_source(source)
36
+ uri = normalize_uri(uri)
37
+ values, bytes = normalize_diagnostics(diagnostics)
38
+ @lock.synchronize do
39
+ key = [source, uri]
40
+ previous = @entries.fetch(key, [])
41
+ total = @total - previous.length + values.length
42
+ raise ArgumentError, "too many diagnostics" if total > TOTAL_LIMIT
43
+ total_bytes = @bytes - @sizes.fetch(key, 0) + bytes
44
+ raise ArgumentError, "diagnostics use too much memory" if total_bytes > TOTAL_BYTES_LIMIT
45
+
46
+ previous.each { |diagnostic| @counts[SEVERITIES.key(diagnostic.fetch("severity", 1))] -= 1 }
47
+ values.each { |diagnostic| @counts[SEVERITIES.key(diagnostic.fetch("severity", 1))] += 1 }
48
+ if values.empty?
49
+ @entries.delete(key)
50
+ @sizes.delete(key)
51
+ else
52
+ @entries[key] = values
53
+ @sizes[key] = bytes
54
+ end
55
+ @total, @bytes = total, total_bytes
56
+ @version += 1
57
+ end
58
+ @on_change&.call(uri) if notify
59
+ values
60
+ end
61
+
62
+ def for_uri(uri)
63
+ uri = normalize_uri(uri)
64
+ @lock.synchronize do
65
+ @entries.flat_map do |(source, current), diagnostics|
66
+ current == uri ? diagnostics.map { |diagnostic| Entry.new(source, current, diagnostic).freeze } : []
67
+ end.freeze
68
+ end
69
+ end
70
+
71
+ def all(severity: nil, source: nil)
72
+ severity = normalize_severity(severity) if severity
73
+ source = normalize_source(source) if source
74
+ @lock.synchronize do
75
+ @entries.flat_map do |(entry_source, uri), diagnostics|
76
+ next [] if source && source != entry_source
77
+
78
+ diagnostics.filter_map do |diagnostic|
79
+ next if severity && diagnostic.fetch("severity", 1) != severity
80
+ Entry.new(entry_source, uri, diagnostic).freeze
81
+ end
82
+ end.freeze
83
+ end
84
+ end
85
+
86
+ def counts
87
+ @lock.synchronize { @counts.dup.freeze }
88
+ end
89
+
90
+ private
91
+
92
+ def normalize_source(source)
93
+ raise ArgumentError, "invalid diagnostic source" unless SOURCES.include?(source)
94
+ source
95
+ end
96
+
97
+ def normalize_uri(uri)
98
+ valid = uri.is_a?(String) && uri.encoding == Encoding::UTF_8 && uri.valid_encoding? &&
99
+ uri.bytesize <= URI_LIMIT && !uri.include?("\0")
100
+ raise ArgumentError, "invalid diagnostic URI" unless valid
101
+ Sadr::Protocol.path(uri)
102
+ uri.dup.freeze
103
+ rescue Sadr::Error
104
+ raise ArgumentError, "invalid diagnostic URI"
105
+ end
106
+
107
+ def normalize_severity(severity)
108
+ severity = SEVERITIES.fetch(severity, severity) if severity.is_a?(Symbol)
109
+ raise ArgumentError, "invalid diagnostic severity" unless severity.is_a?(Integer) && severity.between?(1, 4)
110
+ severity
111
+ end
112
+
113
+ def normalize_diagnostics(diagnostics)
114
+ unless diagnostics.is_a?(Array) && diagnostics.length <= PUBLICATION_LIMIT
115
+ raise ArgumentError, "invalid diagnostics"
116
+ end
117
+ Sadr::Protocol.diagnostics(diagnostics)
118
+ bytes = 0
119
+ values = diagnostics.map do |diagnostic|
120
+ message = diagnostic.fetch("message")
121
+ valid = message.encoding == Encoding::UTF_8 && message.valid_encoding? &&
122
+ message.bytesize <= MESSAGE_LIMIT && !message.include?("\0")
123
+ raise ArgumentError, "invalid diagnostic message" unless valid
124
+
125
+ validate_value(diagnostic)
126
+ encoded = JSON.generate(diagnostic)
127
+ raise ArgumentError, "diagnostic exceeds 1 MiB" if encoded.bytesize > ITEM_BYTES_LIMIT
128
+ bytes += encoded.bytesize
129
+ raise ArgumentError, "diagnostics use too much memory" if bytes > TOTAL_BYTES_LIMIT
130
+ freeze_value(JSON.parse(encoded))
131
+ end.freeze
132
+ [values, bytes]
133
+ rescue JSON::GeneratorError, JSON::ParserError, JSON::NestingError, Sadr::Error, KeyError, TypeError
134
+ raise ArgumentError, "invalid diagnostics"
135
+ end
136
+
137
+ def freeze_value(value)
138
+ value.each { |key, child| key.freeze; freeze_value(child) } if value.is_a?(Hash)
139
+ value.each { |child| freeze_value(child) } if value.is_a?(Array)
140
+ value.freeze
141
+ end
142
+
143
+ def validate_value(value, depth = 0)
144
+ raise ArgumentError, "diagnostic is too deeply nested" if depth > VALUE_DEPTH_LIMIT
145
+
146
+ case value
147
+ when Hash
148
+ raise ArgumentError, "invalid diagnostic" unless value.length <= PUBLICATION_LIMIT
149
+ value.each do |key, child|
150
+ validate_string(key)
151
+ validate_value(child, depth + 1)
152
+ end
153
+ when Array
154
+ raise ArgumentError, "invalid diagnostic" if value.length > PUBLICATION_LIMIT
155
+ value.each { |child| validate_value(child, depth + 1) }
156
+ when String then validate_string(value)
157
+ when Float
158
+ raise ArgumentError, "invalid diagnostic" unless value.finite?
159
+ when NilClass, TrueClass, FalseClass, Integer
160
+ nil
161
+ else
162
+ raise ArgumentError, "invalid diagnostic"
163
+ end
164
+ end
165
+
166
+ def validate_string(value)
167
+ valid = value.is_a?(String) && value.encoding == Encoding::UTF_8 && value.valid_encoding? &&
168
+ value.bytesize <= MESSAGE_LIMIT && !value.include?("\0")
169
+ raise ArgumentError, "invalid diagnostic text" unless valid
170
+ end
171
+ end
172
+ end
173
+ end
@@ -62,10 +62,14 @@ class Canopus::DisplayMap::OverlayMap
62
62
  blocks[row] << Block.new(item, row, height.to_f, (height.to_f / line_height).ceil, position)
63
63
  end
64
64
  end
65
- inlines.each_value { |values| values.sort_by! { |value| [value.offset, value.item.priority] }.freeze }
65
+ inline_values = inlines.values.flatten.each_with_index
66
+ .sort_by { |value, index| [value.offset, value.item.priority, index] }.map!(&:first).freeze
67
+ inlines.each_value do |values|
68
+ ordered = values.each_with_index.sort_by { |value, index| [value.offset, value.item.priority, index] }.map!(&:first)
69
+ values.replace(ordered).freeze
70
+ end
66
71
  blocks.each_value { |values| values.sort_by! { |value| value.item.priority }.freeze }
67
72
  inlines, blocks = inlines.to_h.freeze, blocks.to_h.freeze
68
- inline_values = inlines.values.flatten.sort_by(&:offset).freeze
69
73
  return [] if @inlines == inlines && @blocks == blocks && @line_height == line_height
70
74
 
71
75
  affected = (@inlines.keys | @blocks.keys | inlines.keys | blocks.keys).select do |row|
@@ -47,12 +47,13 @@ module Canopus
47
47
  def insert_snippet(source, variables: {}, workspace_root: nil, clipboard: nil)
48
48
  raise Error, "snippet variables must be a Hash" unless variables.is_a?(Hash)
49
49
  # Parse every cursor expansion before changing text or disposing a live session.
50
- snippets = @selections.each_with_index.map do |selection, index|
50
+ selections = merged_selections(@selections, touching: false)
51
+ snippets = selections.each_with_index.map do |selection, index|
51
52
  builtins = snippet_variables(workspace_root: workspace_root, clipboard: clipboard, selection: selection, cursor_index: index)
52
53
  Snippet.new(source, variables: builtins.merge(variables.transform_keys(&:to_s)))
53
54
  end
54
55
  starts, delta = [], 0
55
- @selections.zip(snippets).each do |selection, snippet|
56
+ selections.zip(snippets).each do |selection, snippet|
56
57
  starts << selection.start + delta
57
58
  delta += snippet.text.bytesize - selection.range.size
58
59
  end