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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -0
- data/README.md +191 -19
- data/assets/keymaps/emacs.jsonc +5 -0
- data/assets/keymaps/jetbrains.jsonc +5 -0
- data/assets/keymaps/sublime.jsonc +5 -0
- data/assets/keymaps/vscode.jsonc +5 -0
- data/docs/debugging.md +143 -0
- data/docs/distribution.md +40 -9
- data/docs/lsp.md +131 -0
- data/docs/performance.md +18 -0
- data/docs/providers.md +36 -0
- data/docs/release-log.md +12 -0
- data/docs/tasks.md +64 -0
- data/docs/testing.md +49 -0
- data/docs/vim.md +3 -0
- data/exe/canopus +4 -1
- data/lib/canopus/buffer.rb +291 -50
- data/lib/canopus/cli.rb +5 -2
- data/lib/canopus/command.rb +13 -4
- data/lib/canopus/controller.rb +183 -40
- data/lib/canopus/debug/breakpoints.rb +550 -0
- data/lib/canopus/debug/configuration.rb +291 -0
- data/lib/canopus/debug/console.rb +243 -0
- data/lib/canopus/debug/panel.rb +487 -0
- data/lib/canopus/debug/session.rb +354 -0
- data/lib/canopus/decoration.rb +4 -2
- data/lib/canopus/denebola_compat.rb +27 -0
- data/lib/canopus/diagnostics.rb +173 -0
- data/lib/canopus/display_map/overlay_map.rb +6 -2
- data/lib/canopus/editor/snippet_expandable.rb +3 -2
- data/lib/canopus/editor.rb +122 -31
- data/lib/canopus/git/state.rb +100 -0
- data/lib/canopus/language/background_analysis.rb +93 -14
- data/lib/canopus/language/document.rb +70 -34
- data/lib/canopus/language/symbol.rb +1 -0
- data/lib/canopus/language/syntax_worker.rb +61 -9
- data/lib/canopus/language.rb +3 -0
- data/lib/canopus/minimap.rb +201 -0
- data/lib/canopus/patch.rb +3 -1
- data/lib/canopus/plugins/api.rb +4 -2
- data/lib/canopus/plugins/isolated_runtime.rb +64 -7
- data/lib/canopus/plugins/registry.rb +114 -3
- data/lib/canopus/plugins.rb +8 -7
- data/lib/canopus/project.rb +15 -6
- data/lib/canopus/provider.rb +192 -0
- data/lib/canopus/regexp_compat.rb +36 -4
- data/lib/canopus/selection.rb +12 -0
- data/lib/canopus/settings/schema.rb +201 -0
- data/lib/canopus/settings.rb +328 -49
- data/lib/canopus/task/configuration.rb +356 -0
- data/lib/canopus/task/problem_matcher.rb +264 -0
- data/lib/canopus/task/runner.rb +314 -0
- data/lib/canopus/test_runner/bounded_ignore.rb +144 -0
- data/lib/canopus/test_runner/discovery.rb +97 -0
- data/lib/canopus/test_runner/execution.rb +194 -0
- data/lib/canopus/test_runner/minitest.rb +116 -0
- data/lib/canopus/test_runner/rspec.rb +103 -0
- data/lib/canopus/test_runner/safe_walker.rb +37 -0
- data/lib/canopus/test_runner.rb +54 -0
- data/lib/canopus/theme/import.rb +131 -0
- data/lib/canopus/theme.rb +28 -4
- data/lib/canopus/version.rb +1 -1
- data/lib/canopus/vim/motionable.rb +1 -1
- data/lib/canopus/vim.rb +10 -16
- data/lib/canopus/workspace/auto_savable.rb +83 -0
- data/lib/canopus/workspace/debug_aware.rb +600 -0
- data/lib/canopus/workspace/edit/plan.rb +2 -2
- data/lib/canopus/workspace/editor_configurable.rb +113 -0
- data/lib/canopus/workspace/file_change_aware.rb +11 -3
- data/lib/canopus/workspace/git_aware.rb +80 -34
- data/lib/canopus/workspace/git_blame.rb +175 -0
- data/lib/canopus/workspace/git_conflict_resolution.rb +410 -0
- data/lib/canopus/workspace/git_diff_view.rb +641 -0
- data/lib/canopus/workspace/git_history.rb +171 -0
- data/lib/canopus/workspace/git_remote.rb +267 -0
- data/lib/canopus/workspace/git_staging.rb +583 -0
- data/lib/canopus/workspace/hierarchy_aware.rb +477 -0
- data/lib/canopus/workspace/keymap_aware.rb +66 -0
- data/lib/canopus/workspace/language_aware.rb +3721 -135
- data/lib/canopus/workspace/language_server_configurable.rb +287 -71
- data/lib/canopus/workspace/persistent_undo.rb +169 -0
- data/lib/canopus/workspace/problems_aware.rb +131 -0
- data/lib/canopus/workspace/project_searchable.rb +88 -11
- data/lib/canopus/workspace/project_tree_editable.rb +3 -1
- data/lib/canopus/workspace/recoverable.rb +204 -0
- data/lib/canopus/workspace/session_persistable.rb +85 -13
- data/lib/canopus/workspace/settings_aware.rb +76 -2
- data/lib/canopus/workspace/task_aware.rb +194 -0
- data/lib/canopus/workspace/test_aware.rb +394 -0
- data/lib/canopus/workspace/trust.rb +73 -0
- data/lib/canopus/workspace/view/terminal_presentable.rb +298 -47
- data/lib/canopus/workspace/view.rb +399 -74
- data/lib/canopus/workspace.rb +688 -44
- data/lib/canopus.rb +20 -2
- data/sig/canopus.rbs +138 -31
- data/sig/controller.rbs +10 -3
- data/sig/debug.rbs +121 -0
- data/sig/decoration.rbs +12 -2
- data/sig/diagnostics.rbs +26 -0
- data/sig/minimap.rbs +20 -0
- data/sig/provider.rbs +29 -0
- data/sig/services.rbs +1 -16
- data/sig/task.rbs +92 -0
- data/sig/test_runner.rbs +109 -0
- data/sig/trust.rbs +17 -0
- data/sig/workspace_edits.rbs +2 -2
- data/sig/workspace_services.rbs +234 -2
- data/tools/check_dependencies.rb +21 -2
- data/tools/package.rb +172 -14
- data/tools/package_signature_test.rb +33 -0
- data/tools/package_test.rb +29 -0
- data/tools/sign_package.rb +123 -0
- data/tools/update.rb +189 -0
- data/tools/update_test.rb +71 -0
- metadata +206 -31
- data/lib/canopus/lsp/client.rb +0 -307
- data/lib/canopus/lsp/error.rb +0 -7
- data/lib/canopus/lsp/future/subscription.rb +0 -9
- data/lib/canopus/lsp/future.rb +0 -87
- data/lib/canopus/lsp/protocol.rb +0 -83
- data/lib/canopus/lsp/server_error.rb +0 -13
- data/lib/canopus/lsp/timeout.rb +0 -7
- data/lib/canopus/lsp/transport.rb +0 -123
- data/lib/canopus/lsp.rb +0 -19
- data/lib/canopus/project/search.rb +0 -164
- data/lib/canopus/project/search_worker/cancelled.rb +0 -3
- data/lib/canopus/project/search_worker/runner.rb +0 -9
- data/lib/canopus/project/search_worker.rb +0 -108
- data/lib/canopus/terminal/cell.rb +0 -7
- data/lib/canopus/terminal/grid.rb +0 -321
- data/lib/canopus/terminal/pty.rb +0 -178
- data/lib/canopus/terminal/scrollback.rb +0 -38
- data/lib/canopus/terminal/vt.rb +0 -398
- data/lib/canopus/terminal.rb +0 -13
- data/sig/lsp.rbs +0 -99
- 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
|