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,550 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "objspace"
6
+ require "tempfile"
7
+ require_relative "../error"
8
+ require_relative "../buffer"
9
+
10
+ module Canopus
11
+ module Debug
12
+ class Breakpoints
13
+ Entry = Data.define(:path, :line, :condition, :hit_condition, :log_message, :enabled)
14
+ VERSION = 2
15
+ RELATIVE_PATH = File.join(".canopus", "breakpoints.json")
16
+ MAX_BYTES = 1_048_576
17
+ MAX_ENTRIES = 10_000
18
+ MAX_PATH_BYTES = 4_096
19
+ MAX_CONDITION_BYTES = 4_096
20
+ MAX_HIT_CONDITION_BYTES = 4_096
21
+ MAX_LOG_MESSAGE_BYTES = 4_096
22
+ MAX_LINE = 2_147_483_647
23
+ SAVE_DELAY = 0.02
24
+ UNSET = Object.new.freeze
25
+ private_constant :SAVE_DELAY, :UNSET
26
+
27
+ attr_reader :root, :entries
28
+
29
+ def initialize(root:, &on_change)
30
+ @root = canonical_root(root)
31
+ @on_change = on_change
32
+ @attachments = {}
33
+ @closed = false
34
+ @entries = load_entries
35
+ @state_lock = Mutex.new
36
+ @save_lock, @save_wake = Mutex.new, ConditionVariable.new
37
+ @save_generation = @saved_generation = 0
38
+ @pending_save = @save_error = nil
39
+ @saving = @save_stopping = false
40
+ @save_worker = Thread.new { save_loop }
41
+ @save_worker.name = "canopus-breakpoints"
42
+ end
43
+
44
+ def for_path(path)
45
+ relative = normalize_path(path)
46
+ entries.select { |entry| entry.path == relative }.freeze
47
+ end
48
+
49
+ def add(path, line, condition: nil, hit_condition: nil, log_message: nil, enabled: true)
50
+ @state_lock.synchronize do
51
+ ensure_open!
52
+ entry = build_entry(path, line, condition, hit_condition, log_message, enabled)
53
+ existing = entries.find { |item| item.path == entry.path && item.line == entry.line }
54
+ return existing if existing == entry
55
+ replacement = entries.reject { |item| item.path == entry.path && item.line == entry.line }
56
+ commit([*replacement, entry], changed_path: entry.path)
57
+ entry
58
+ end
59
+ end
60
+
61
+ def remove(path, line)
62
+ @state_lock.synchronize do
63
+ ensure_open!
64
+ relative, line = normalize_path(path), validate_line(line)
65
+ replacement = entries.reject { |entry| entry.path == relative && entry.line == line }
66
+ return false if replacement.length == entries.length
67
+ commit(replacement, changed_path: relative)
68
+ true
69
+ end
70
+ end
71
+
72
+ def toggle(path, line, condition: nil, hit_condition: nil, log_message: nil, enabled: true)
73
+ existing = for_path(path).find { |entry| entry.line == line }
74
+ return nil if existing && remove(path, line)
75
+ add(path, line, condition: condition, hit_condition: hit_condition, log_message: log_message, enabled: enabled)
76
+ end
77
+
78
+ def update(path, line, new_line: line, condition: UNSET, hit_condition: UNSET, log_message: UNSET, enabled: UNSET)
79
+ @state_lock.synchronize do
80
+ ensure_open!
81
+ relative, line = normalize_path(path), validate_line(line)
82
+ current = entries.find { |entry| entry.path == relative && entry.line == line }
83
+ raise Error, "breakpoint does not exist" unless current
84
+ condition = current.condition if condition.equal?(UNSET)
85
+ hit_condition = current.hit_condition if hit_condition.equal?(UNSET)
86
+ log_message = current.log_message if log_message.equal?(UNSET)
87
+ enabled = current.enabled if enabled.equal?(UNSET)
88
+ replacement = build_entry(relative, new_line, condition, hit_condition, log_message, enabled)
89
+ collision = entries.any? { |entry| !entry.equal?(current) && entry.path == relative && entry.line == replacement.line }
90
+ raise Error, "breakpoint already exists" if collision
91
+ return current if current == replacement
92
+ commit(entries.map { |entry| entry.equal?(current) ? replacement : entry }, changed_path: relative)
93
+ replacement
94
+ end
95
+ end
96
+
97
+ def attach(buffer)
98
+ @state_lock.synchronize do
99
+ ensure_open!
100
+ raise Error, "breakpoints require a file-backed buffer" unless buffer.is_a?(Buffer) && buffer.path
101
+ return self if @attachments.key?(buffer)
102
+ path = normalize_path(buffer.path)
103
+ ensure_attachment_path_available!(buffer, path)
104
+ attachment = {snapshots: ObjectSpace::WeakMap.new, states: {}.compare_by_identity,
105
+ path: path, revision: 0, gc_count: GC.count}
106
+ store_snapshot(attachment, buffer.rope, entries_for(path))
107
+ subscription = buffer.on_edit { |patch| edited(buffer, patch) }
108
+ attachment[:subscription] = subscription
109
+ @attachments[buffer] = attachment
110
+ end
111
+ self
112
+ end
113
+
114
+ def detach(buffer)
115
+ attachment = @state_lock.synchronize { @attachments.delete(buffer) }
116
+ attachment&.fetch(:subscription)&.detach
117
+ flush if attachment
118
+ !attachment.nil?
119
+ end
120
+
121
+ def close
122
+ attachments = @state_lock.synchronize do
123
+ return nil if @closed
124
+ @closed = true
125
+ values = @attachments.values
126
+ @attachments.clear
127
+ values
128
+ end
129
+ attachments.each { |attachment| attachment.fetch(:subscription).detach }
130
+ flush
131
+ nil
132
+ ensure
133
+ stop_save_worker if attachments
134
+ end
135
+
136
+ def flush
137
+ target = @save_lock.synchronize { @save_generation }
138
+ @save_lock.synchronize do
139
+ until @saved_generation >= target
140
+ if !@saving && !@pending_save && @save_error && @save_error.first >= target
141
+ raise @save_error.last
142
+ end
143
+ @save_wake.wait(@save_lock)
144
+ end
145
+ end
146
+ self
147
+ end
148
+
149
+ def error
150
+ @save_lock.synchronize { @save_error&.last }
151
+ end
152
+
153
+ private
154
+
155
+ def edited(buffer, patch)
156
+ @state_lock.synchronize do
157
+ attachment = @attachments[buffer]
158
+ return unless attachment
159
+ begin
160
+ path = normalize_path(buffer.path)
161
+ unless path == attachment.fetch(:path)
162
+ ensure_attachment_path_available!(buffer, path)
163
+ relocate_attachment(attachment, patch.before, path)
164
+ end
165
+ revision = attachment.fetch(:revision)
166
+ snapshots = attachment.fetch(:snapshots)
167
+ saved = snapshots[patch.after]
168
+ mapped = saved && saved[0] == revision ? saved[1] : map_entries(entries_for(path), patch)
169
+ current = entries_for(path)
170
+ if mapped != current
171
+ @entries = sorted_entries([*entries.reject { |entry| entry.path == path }, *mapped])
172
+ schedule_save(@entries)
173
+ notify_change(path)
174
+ end
175
+ store_snapshot(attachment, patch.after, mapped)
176
+ rescue StandardError
177
+ @attachments.delete(buffer)
178
+ attachment.fetch(:subscription).detach
179
+ raise
180
+ end
181
+ end
182
+ end
183
+
184
+ def map_entries(items, patch)
185
+ if patch.is_a?(Patch::Composite)
186
+ return patch.patches.reduce(items) { |current, child| map_entries(current, child) }
187
+ end
188
+ seen = {}
189
+ mapped = items.filter_map do |entry|
190
+ mapped_entry = map_entry(entry, patch)
191
+ next unless mapped_entry
192
+ key = [mapped_entry.path, mapped_entry.line]
193
+ next if seen[key]
194
+ seen[key] = true
195
+ mapped_entry
196
+ end
197
+ mapped == items ? items : mapped.freeze
198
+ end
199
+
200
+ def map_entry(entry, patch)
201
+ row = entry.line - 1
202
+ return entry if row >= patch.before.line_count
203
+ start = patch.before.line_start(row)
204
+ content_end = start + patch.before.line(row).bytesize
205
+ full_end = row + 1 < patch.before.line_count ? patch.before.line_start(row + 1) : patch.before.bytesize
206
+ identity_end = content_end == start ? full_end : content_end
207
+ if identity_end > start
208
+ covered, replacement = line_change(patch.edits, start, identity_end)
209
+ return mapped_entry(entry, patch.after, replacement) if covered && replacement
210
+ return if covered
211
+ end
212
+ return if deleted_final_empty_line?(patch, row, start)
213
+ position = patch.map_offset(start, bias: :right)
214
+ mapped_entry(entry, patch.after, position)
215
+ end
216
+
217
+ def line_change(edits, start, ending)
218
+ cursor, replacement, delta = start, nil, 0
219
+ edits.sort_by { |edit| [edit.old_range.begin, edit.old_range.end] }.each do |edit|
220
+ break if cursor >= ending && edit.old_range.begin > ending
221
+ new_start = edit.old_range.begin + delta
222
+ relevant = if edit.old_range.begin == edit.old_range.end
223
+ edit.old_range.begin.between?(start, ending)
224
+ else
225
+ edit.old_range.begin < ending && edit.old_range.end > start
226
+ end
227
+ replacement ||= new_start if relevant && !edit.new_text.empty?
228
+ if edit.old_range.end > cursor
229
+ return [false, nil] if edit.old_range.begin > cursor
230
+ cursor = edit.old_range.end
231
+ end
232
+ delta += edit.new_text.bytesize - (edit.old_range.end - edit.old_range.begin)
233
+ end
234
+ [cursor >= ending, replacement]
235
+ end
236
+
237
+ def deleted_final_empty_line?(patch, row, start)
238
+ return false unless row == patch.before.line_count - 1 && row.positive? && start == patch.before.bytesize
239
+ break_start = patch.before.line_start(row - 1) + patch.before.line(row - 1).bytesize
240
+ return true if deletions_cover?(patch.edits, break_start, start)
241
+ replacement = patch.edits.find do |edit|
242
+ edit.old_range.begin <= break_start && edit.old_range.end >= start && !edit.new_text.empty?
243
+ end
244
+ replacement && !replacement.new_text.match?(/(?:\r\n|[\r\n\u2028\u2029])\z/)
245
+ end
246
+
247
+ def deletions_cover?(edits, start, ending)
248
+ cursor = start
249
+ edits.each do |edit|
250
+ next unless edit.new_text.empty? && edit.old_range.end > cursor
251
+ return false if edit.old_range.begin > cursor
252
+ cursor = [cursor, edit.old_range.end].max
253
+ return true if cursor >= ending
254
+ end
255
+ false
256
+ end
257
+
258
+ def mapped_entry(entry, rope, position)
259
+ position = position.clamp(0, rope.bytesize)
260
+ line = rope.point_at(position).row + 1
261
+ line == entry.line ? entry : Entry.new(entry.path, line, entry.condition,
262
+ entry.hit_condition, entry.log_message, entry.enabled)
263
+ end
264
+
265
+ def commit(values, changed_path:)
266
+ snapshot = sorted_entries(values)
267
+ persist_now(snapshot)
268
+ @entries = snapshot
269
+ refresh_attachments(changed_path)
270
+ notify_change(changed_path)
271
+ end
272
+
273
+ def sorted_entries(values)
274
+ raise Error, "too many breakpoints" if values.length > MAX_ENTRIES
275
+ values.sort_by { |entry| [entry.path, entry.line] }.freeze
276
+ end
277
+
278
+ def refresh_attachments(changed_path)
279
+ @attachments.each do |buffer, attachment|
280
+ next unless attachment.fetch(:path) == changed_path
281
+ attachment[:revision] += 1
282
+ store_snapshot(attachment, buffer.rope, entries_for(changed_path))
283
+ end
284
+ end
285
+
286
+ def relocate_attachment(attachment, rope, path)
287
+ attachment[:path] = path
288
+ attachment[:revision] += 1
289
+ store_snapshot(attachment, rope, entries_for(path))
290
+ end
291
+
292
+ def ensure_attachment_path_available!(buffer, path)
293
+ duplicate = @attachments.any? do |candidate, attachment|
294
+ !candidate.equal?(buffer) && attachment.fetch(:path) == path
295
+ end
296
+ raise Error, "breakpoint path is already attached" if duplicate
297
+ end
298
+
299
+ def store_snapshot(attachment, rope, items)
300
+ state = [attachment.fetch(:revision), items].freeze
301
+ states = attachment.fetch(:states)
302
+ snapshots = attachment.fetch(:snapshots)
303
+ existing = snapshots[rope]
304
+ if existing == state
305
+ prune_snapshot_states(attachment)
306
+ return
307
+ end
308
+ if existing
309
+ replacement = ObjectSpace::WeakMap.new
310
+ snapshots.each_pair { |key, value| replacement[key] = value unless key.equal?(rope) }
311
+ attachment[:snapshots] = snapshots = replacement
312
+ states.delete(existing)
313
+ end
314
+ states[state] = true # WeakMap values are also weak on Ruby 3.1.
315
+ snapshots[rope] = state
316
+ prune_snapshot_states(attachment)
317
+ end
318
+
319
+ def prune_snapshot_states(attachment)
320
+ count = GC.count
321
+ return if count == attachment.fetch(:gc_count)
322
+ attachment[:gc_count] = count
323
+ snapshots = attachment.fetch(:snapshots)
324
+ states = attachment.fetch(:states)
325
+ live = {}.compare_by_identity
326
+ snapshots.each_value { |value| live[value] = true }
327
+ states.delete_if { |value, _| !live.key?(value) }
328
+ end
329
+
330
+ def entries_for(path)
331
+ entries.select { |entry| entry.path == path }.freeze
332
+ end
333
+
334
+ def schedule_save(snapshot)
335
+ @save_lock.synchronize do
336
+ @save_generation += 1
337
+ @pending_save = [@save_generation, snapshot]
338
+ @save_wake.broadcast
339
+ end
340
+ end
341
+
342
+ def persist_now(snapshot)
343
+ generation = @save_lock.synchronize do
344
+ @save_wake.wait(@save_lock) while @saving || @pending_save
345
+ @save_generation += 1
346
+ @saving = true
347
+ @save_generation
348
+ end
349
+ failure = nil
350
+ begin
351
+ persist(snapshot)
352
+ rescue StandardError => error
353
+ failure = error
354
+ ensure
355
+ finish_save(generation, failure)
356
+ end
357
+ raise failure if failure
358
+ end
359
+
360
+ def save_loop
361
+ loop do
362
+ job = next_save
363
+ break unless job
364
+ generation, snapshot = job
365
+ failure = nil
366
+ begin
367
+ persist(snapshot)
368
+ rescue StandardError => error
369
+ failure = error
370
+ ensure
371
+ finish_save(generation, failure)
372
+ end
373
+ end
374
+ end
375
+
376
+ def next_save
377
+ @save_lock.synchronize do
378
+ @save_wake.wait(@save_lock) until @save_stopping || @pending_save
379
+ return if @save_stopping && !@pending_save
380
+ deadline = monotonic_time + SAVE_DELAY
381
+ loop do
382
+ remaining = deadline - monotonic_time
383
+ break unless remaining.positive? && !@save_stopping
384
+ pending = @pending_save
385
+ @save_wake.wait(@save_lock, remaining)
386
+ deadline = monotonic_time + SAVE_DELAY unless pending.equal?(@pending_save)
387
+ end
388
+ job = @pending_save
389
+ @pending_save = nil
390
+ @saving = true
391
+ job
392
+ end
393
+ end
394
+
395
+ def finish_save(generation, failure)
396
+ @save_lock.synchronize do
397
+ @saving = false
398
+ if failure
399
+ @save_error = [generation, failure]
400
+ else
401
+ @saved_generation = generation
402
+ @save_error = nil
403
+ end
404
+ @save_wake.broadcast
405
+ end
406
+ end
407
+
408
+ def stop_save_worker
409
+ @save_lock.synchronize do
410
+ @save_stopping = true
411
+ @save_wake.broadcast
412
+ end
413
+ @save_worker.join
414
+ end
415
+
416
+ def monotonic_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
417
+
418
+ def build_entry(path, line, condition, hit_condition = nil, log_message = nil, enabled = true)
419
+ path = normalize_path(path)
420
+ line = validate_line(line)
421
+ unless condition.nil? || valid_text?(condition, MAX_CONDITION_BYTES)
422
+ raise Error, "invalid breakpoint condition"
423
+ end
424
+ unless hit_condition.nil? || valid_text?(hit_condition, MAX_HIT_CONDITION_BYTES)
425
+ raise Error, "invalid breakpoint hit condition"
426
+ end
427
+ unless log_message.nil? || valid_text?(log_message, MAX_LOG_MESSAGE_BYTES)
428
+ raise Error, "invalid breakpoint log message"
429
+ end
430
+ raise Error, "invalid breakpoint enabled state" unless enabled == true || enabled == false
431
+ Entry.new(path.dup.freeze, line, condition&.dup&.freeze,
432
+ hit_condition&.dup&.freeze, log_message&.dup&.freeze, enabled)
433
+ end
434
+
435
+ def validate_line(line)
436
+ raise Error, "invalid breakpoint line" unless line.is_a?(Integer) && line.between?(1, MAX_LINE)
437
+ line
438
+ end
439
+
440
+ def valid_text?(value, maximum)
441
+ value.is_a?(String) && value.valid_encoding? &&
442
+ (value.encoding == Encoding::UTF_8 || value.ascii_only?) &&
443
+ value.bytesize.between?(1, maximum) && !value.match?(/[\x00-\x1f\x7f]/)
444
+ end
445
+
446
+ def normalize_path(value)
447
+ raise Error, "invalid breakpoint path" unless valid_text?(value, MAX_PATH_BYTES)
448
+ path = File.expand_path(value, root)
449
+ if File.exist?(path)
450
+ path = File.realpath(path)
451
+ else
452
+ ancestor, suffix = path, []
453
+ until File.exist?(ancestor) || File.symlink?(ancestor) || File.dirname(ancestor) == ancestor
454
+ suffix.unshift(File.basename(ancestor))
455
+ ancestor = File.dirname(ancestor)
456
+ end
457
+ path = File.join(File.realpath(ancestor), *suffix)
458
+ end
459
+ prefix = root.end_with?(File::SEPARATOR) ? root : "#{root}#{File::SEPARATOR}"
460
+ raise Error, "breakpoint path is outside the workspace" unless path.start_with?(prefix)
461
+ raise Error, "breakpoint path must not be a directory" if File.directory?(path)
462
+ relative = path.delete_prefix(prefix)
463
+ relative = relative.tr(File::ALT_SEPARATOR, "/") if File::ALT_SEPARATOR
464
+ raise Error, "breakpoint path is too long" if relative.bytesize > MAX_PATH_BYTES
465
+ relative.freeze
466
+ rescue SystemCallError, ArgumentError => error
467
+ raise Error, "invalid breakpoint path: #{error.message}"
468
+ end
469
+
470
+ def canonical_root(value)
471
+ path = File.realpath(value)
472
+ raise Error, "breakpoint root must be a directory" unless File.directory?(path)
473
+ path.freeze
474
+ rescue SystemCallError, TypeError => error
475
+ raise Error, "invalid breakpoint root: #{error.message}"
476
+ end
477
+
478
+ def load_entries
479
+ _, path = storage_paths
480
+ return [].freeze unless File.exist?(path)
481
+ raise Error, "breakpoint file must be a regular file" unless File.file?(path)
482
+ raise Error, "breakpoint file exceeds 1 MiB" if File.size(path) > MAX_BYTES
483
+ source = File.binread(path, MAX_BYTES + 1).force_encoding(Encoding::UTF_8)
484
+ raise Error, "invalid breakpoint file" unless source.valid_encoding? && source.bytesize <= MAX_BYTES
485
+ document = JSON.parse(source)
486
+ validate_document(document)
487
+ rescue JSON::ParserError, JSON::NestingError
488
+ raise Error, "invalid breakpoint file"
489
+ rescue SystemCallError => error
490
+ raise Error, "cannot read breakpoint file: #{error.message}"
491
+ end
492
+
493
+ def validate_document(document)
494
+ unless document.is_a?(Hash) && document.keys.sort == %w[breakpoints version] && [1, VERSION].include?(document["version"])
495
+ raise Error, "unsupported breakpoint file"
496
+ end
497
+ version = document["version"]
498
+ values = document["breakpoints"]
499
+ raise Error, "invalid breakpoint list" unless values.is_a?(Array) && values.length <= MAX_ENTRIES
500
+ loaded = values.map do |value|
501
+ keys = version == 1 ? %w[condition line path] : %w[condition enabled hit_condition line log_message path]
502
+ unless value.is_a?(Hash) && value.keys.sort == keys
503
+ raise Error, "invalid breakpoint entry"
504
+ end
505
+ build_entry(value["path"], value["line"], value["condition"],
506
+ value["hit_condition"], value["log_message"], version == 1 ? true : value["enabled"])
507
+ end
508
+ keys = loaded.map { |entry| [entry.path, entry.line] }
509
+ raise Error, "duplicate breakpoint" unless keys.uniq.length == keys.length
510
+ loaded.sort_by { |entry| [entry.path, entry.line] }.freeze
511
+ end
512
+
513
+ def persist(snapshot)
514
+ directory, path = storage_paths(create: true)
515
+ payload = JSON.generate("version" => VERSION, "breakpoints" => snapshot.map do |entry|
516
+ {"path" => entry.path, "line" => entry.line, "condition" => entry.condition,
517
+ "hit_condition" => entry.hit_condition, "log_message" => entry.log_message, "enabled" => entry.enabled}
518
+ end)
519
+ raise Error, "breakpoint file exceeds 1 MiB" if payload.bytesize > MAX_BYTES
520
+ Tempfile.create([".breakpoints-", ".json"], directory, mode: File::RDWR, perm: 0o600) do |file|
521
+ file.write(payload)
522
+ file.flush
523
+ file.fsync
524
+ file.close
525
+ File.rename(file.path, path)
526
+ end
527
+ rescue JSON::GeneratorError, SystemCallError => error
528
+ raise Error, "cannot save breakpoints: #{error.message}"
529
+ end
530
+
531
+ def storage_paths(create: false)
532
+ directory = File.join(root, ".canopus")
533
+ raise Error, "breakpoint directory must not be a symlink" if File.symlink?(directory)
534
+ FileUtils.mkdir_p(directory) if create && !File.exist?(directory)
535
+ if File.exist?(directory) && (!File.directory?(directory) || File.realpath(directory) != directory)
536
+ raise Error, "invalid breakpoint directory"
537
+ end
538
+ path = File.join(directory, "breakpoints.json")
539
+ raise Error, "breakpoint file must not be a symlink" if File.symlink?(path)
540
+ [directory, path]
541
+ end
542
+
543
+ def ensure_open!
544
+ raise Error, "breakpoint registry is closed" if @closed
545
+ end
546
+
547
+ def notify_change(path) = @on_change&.call(path)
548
+ end
549
+ end
550
+ end