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,314 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canopus
|
|
4
|
+
module Task
|
|
5
|
+
class Runner
|
|
6
|
+
Output = Data.define(:id, :label, :terminal, :presentation)
|
|
7
|
+
MAX_OUTPUTS = 32
|
|
8
|
+
MAX_CLOSERS = 32
|
|
9
|
+
STOP_GRACE_SECONDS = 0.25
|
|
10
|
+
|
|
11
|
+
attr_reader :active_index
|
|
12
|
+
|
|
13
|
+
def initialize(scrollback:, queue_limit_bytes:, terminal_factory: nil, report: nil)
|
|
14
|
+
unless scrollback.is_a?(Integer) && scrollback.between?(0, 1_000_000)
|
|
15
|
+
raise ArgumentError, "invalid task scrollback limit"
|
|
16
|
+
end
|
|
17
|
+
unless queue_limit_bytes.is_a?(Integer) && queue_limit_bytes.between?(65_536, 268_435_456)
|
|
18
|
+
raise ArgumentError, "invalid task queue limit"
|
|
19
|
+
end
|
|
20
|
+
@scrollback, @queue_limit_bytes = scrollback, queue_limit_bytes
|
|
21
|
+
@terminal_factory = terminal_factory || ->(**options) { Tarazed::PTY.new(**options) }
|
|
22
|
+
@report = report || ->(_error) {}
|
|
23
|
+
@entries, @completed = [], {}
|
|
24
|
+
@close_threads = {}.compare_by_identity
|
|
25
|
+
@closed_outputs = {}.compare_by_identity
|
|
26
|
+
@eof_outputs = {}.compare_by_identity
|
|
27
|
+
@blocked_outputs = {}.compare_by_identity
|
|
28
|
+
@active_index = @sequence = @poll_index = 0
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def entries = @entries.dup.freeze
|
|
32
|
+
def active = @entries[@active_index]
|
|
33
|
+
def terminal = active&.terminal
|
|
34
|
+
|
|
35
|
+
def run(task)
|
|
36
|
+
raise Error, "task runner is closed" if @closed
|
|
37
|
+
validate_task!(task)
|
|
38
|
+
reap_closers
|
|
39
|
+
previous = @entries.find { |entry| entry.label == task.fetch("label") }
|
|
40
|
+
evicted = previous || eviction_candidate
|
|
41
|
+
ensure_closer_capacity! if evicted && !@closed_outputs.key?(evicted)
|
|
42
|
+
dimensions = @dimensions || [100, 12]
|
|
43
|
+
terminal = @terminal_factory.call(command: task.fetch("command"), cwd: task.fetch("cwd"),
|
|
44
|
+
env: task.fetch("env", {}),
|
|
45
|
+
columns: dimensions.first, rows: dimensions.last, scrollback: @scrollback,
|
|
46
|
+
queue_limit_bytes: @queue_limit_bytes)
|
|
47
|
+
@sequence += 1
|
|
48
|
+
entry = Output.new(@sequence, task.fetch("label"), terminal, task.fetch("presentation")).freeze
|
|
49
|
+
if evicted
|
|
50
|
+
index = @entries.index { |current| current.equal?(evicted) }
|
|
51
|
+
close_later(evicted)
|
|
52
|
+
@completed.delete(evicted.id)
|
|
53
|
+
@eof_outputs.delete(evicted)
|
|
54
|
+
@blocked_outputs.delete(evicted)
|
|
55
|
+
@entries[index] = entry
|
|
56
|
+
@closed_outputs.delete(evicted) unless @close_threads.key?(evicted)
|
|
57
|
+
@active_index = index
|
|
58
|
+
else
|
|
59
|
+
@entries << entry
|
|
60
|
+
@active_index = @entries.length - 1
|
|
61
|
+
end
|
|
62
|
+
entry
|
|
63
|
+
rescue StandardError
|
|
64
|
+
terminal&.close unless @entries.any? { |entry| entry.terminal.equal?(terminal) }
|
|
65
|
+
raise
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def activate(index)
|
|
69
|
+
unless index.is_a?(Integer) && index.between?(0, @entries.length - 1)
|
|
70
|
+
raise IndexError, "task output tab outside panel"
|
|
71
|
+
end
|
|
72
|
+
@active_index = index
|
|
73
|
+
active
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def remove(index = @active_index)
|
|
77
|
+
entry = @entries[index]
|
|
78
|
+
return unless entry
|
|
79
|
+
|
|
80
|
+
close_later(entry)
|
|
81
|
+
@entries.delete_at(index)
|
|
82
|
+
@completed.delete(entry.id)
|
|
83
|
+
@eof_outputs.delete(entry)
|
|
84
|
+
@blocked_outputs.delete(entry)
|
|
85
|
+
@closed_outputs.delete(entry) unless @close_threads.key?(entry)
|
|
86
|
+
@active_index = [index, @entries.length - 1].min.clamp(0, @entries.length)
|
|
87
|
+
entry
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def stop(entry = active)
|
|
91
|
+
return false unless entry && @entries.any? { |current| current.equal?(entry) }
|
|
92
|
+
return false unless running?(entry)
|
|
93
|
+
|
|
94
|
+
close_later(entry, interrupt: true)
|
|
95
|
+
true
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def running?(entry = active)
|
|
99
|
+
return false unless entry && !@closed_outputs.key?(entry)
|
|
100
|
+
terminal = entry.terminal
|
|
101
|
+
!terminal.respond_to?(:alive?) || terminal.alive?
|
|
102
|
+
rescue IOError, SystemCallError
|
|
103
|
+
false
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def completed
|
|
107
|
+
@entries.filter_map do |entry|
|
|
108
|
+
next if @completed[entry.id] || running?(entry) || !@eof_outputs.key?(entry)
|
|
109
|
+
@completed[entry.id] = true
|
|
110
|
+
entry
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def drain(max_bytes:, max_seconds: 0.004)
|
|
115
|
+
raise ArgumentError, "task drain limit must be positive" unless max_bytes.is_a?(Integer) && max_bytes.positive?
|
|
116
|
+
unless max_seconds.is_a?(Numeric) && max_seconds.finite? && max_seconds >= 0
|
|
117
|
+
raise ArgumentError, "task drain budget must be nonnegative and finite"
|
|
118
|
+
end
|
|
119
|
+
reap_closers
|
|
120
|
+
candidates = @entries.reject { |entry| @eof_outputs.key?(entry) }
|
|
121
|
+
.select { |entry| !@closed_outputs.key?(entry) || @blocked_outputs.key?(entry) }
|
|
122
|
+
return false if candidates.empty?
|
|
123
|
+
|
|
124
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + max_seconds
|
|
125
|
+
remaining, changed = max_bytes, false
|
|
126
|
+
order = candidates.rotate(@poll_index % candidates.length)
|
|
127
|
+
@poll_index += 1
|
|
128
|
+
order.each do |entry|
|
|
129
|
+
break if remaining <= 0 || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
130
|
+
if @blocked_outputs.key?(entry)
|
|
131
|
+
ready = !block_given? || yield(entry, "".b, remaining_time(deadline)) != false
|
|
132
|
+
if ready
|
|
133
|
+
@blocked_outputs.delete(entry)
|
|
134
|
+
if @closed_outputs.key?(entry) && !@close_threads.key?(entry)
|
|
135
|
+
@eof_outputs[entry] = true
|
|
136
|
+
next
|
|
137
|
+
end
|
|
138
|
+
else
|
|
139
|
+
next
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
next if @closed_outputs.key?(entry)
|
|
143
|
+
next if remaining_time(deadline).zero?
|
|
144
|
+
current = entry.terminal
|
|
145
|
+
method = current.method(:read)
|
|
146
|
+
keywords = method.parameters.any? { |kind, _| [:key, :keyreq, :keyrest].include?(kind) }
|
|
147
|
+
data = if keywords
|
|
148
|
+
current.read(max_bytes: remaining,
|
|
149
|
+
max_seconds: [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max)
|
|
150
|
+
else
|
|
151
|
+
current.read
|
|
152
|
+
end
|
|
153
|
+
ready = !block_given? || yield(entry, data, remaining_time(deadline)) != false
|
|
154
|
+
@blocked_outputs[entry] = true unless ready
|
|
155
|
+
@eof_outputs[entry] = true if data.nil? && ready
|
|
156
|
+
changed ||= !!(data && !data.empty?)
|
|
157
|
+
remaining -= data.bytesize if data
|
|
158
|
+
end
|
|
159
|
+
changed
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def pending?
|
|
163
|
+
reap_closers
|
|
164
|
+
@entries.any? do |entry|
|
|
165
|
+
next running?(entry) if @eof_outputs.key?(entry)
|
|
166
|
+
if @closed_outputs.key?(entry)
|
|
167
|
+
next @blocked_outputs.key?(entry) || @close_threads.key?(entry)
|
|
168
|
+
end
|
|
169
|
+
@blocked_outputs.key?(entry) || !running?(entry) ||
|
|
170
|
+
entry.terminal.respond_to?(:pending?) && entry.terminal.pending?
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def resize(columns, rows)
|
|
175
|
+
dimensions = [columns.to_i, rows.to_i]
|
|
176
|
+
return if dimensions.any? { |value| value <= 0 }
|
|
177
|
+
@dimensions = dimensions
|
|
178
|
+
@entries.each do |entry|
|
|
179
|
+
next if @closed_outputs.key?(entry) || (@sizes ||= {}.compare_by_identity)[entry.terminal] == dimensions
|
|
180
|
+
entry.terminal.resize(columns: dimensions.first, rows: dimensions.last)
|
|
181
|
+
@sizes[entry.terminal] = dimensions
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def close
|
|
186
|
+
return if @closed
|
|
187
|
+
@closed = true
|
|
188
|
+
begin
|
|
189
|
+
@close_threads.each_value(&:join)
|
|
190
|
+
reap_closers
|
|
191
|
+
@entries.each do |entry|
|
|
192
|
+
next if @closed_outputs.key?(entry)
|
|
193
|
+
begin
|
|
194
|
+
close_later(entry)
|
|
195
|
+
rescue StandardError => error
|
|
196
|
+
record_close_error(error)
|
|
197
|
+
close_now(entry)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
ensure
|
|
201
|
+
@close_threads.each_value do |thread|
|
|
202
|
+
thread.join
|
|
203
|
+
rescue StandardError => error
|
|
204
|
+
record_close_error(error)
|
|
205
|
+
end
|
|
206
|
+
@entries.each { |entry| close_now(entry) unless @closed_outputs.key?(entry) }
|
|
207
|
+
@entries.clear
|
|
208
|
+
@close_threads.clear
|
|
209
|
+
@closed_outputs.clear
|
|
210
|
+
@completed.clear
|
|
211
|
+
@eof_outputs.clear
|
|
212
|
+
@blocked_outputs.clear
|
|
213
|
+
@sizes&.clear
|
|
214
|
+
end
|
|
215
|
+
raise @close_error if @close_error
|
|
216
|
+
nil
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
private
|
|
220
|
+
|
|
221
|
+
def remaining_time(deadline)
|
|
222
|
+
[deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def validate_task!(task)
|
|
226
|
+
environment = task.is_a?(Hash) ? task.fetch("env", {}) : nil
|
|
227
|
+
valid = task.is_a?(Hash) && task["label"].is_a?(String) && !task["label"].empty? &&
|
|
228
|
+
task["command"].is_a?(Array) && !task["command"].empty? && task["command"].all? { |part| part.is_a?(String) } &&
|
|
229
|
+
task["cwd"].is_a?(String) && task["presentation"].is_a?(Hash) && valid_environment?(environment)
|
|
230
|
+
raise ArgumentError, "resolved task required" unless valid
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def valid_environment?(environment)
|
|
234
|
+
return false unless environment.is_a?(Hash) && environment.length <= 4_096
|
|
235
|
+
|
|
236
|
+
bytes = 0
|
|
237
|
+
environment.all? do |key, value|
|
|
238
|
+
next false unless key.is_a?(String) && key.ascii_only? && key.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
|
|
239
|
+
next false unless value.nil? || value.is_a?(String) && value.valid_encoding? && !value.include?("\0")
|
|
240
|
+
|
|
241
|
+
bytes += key.bytesize + value.to_s.bytesize
|
|
242
|
+
bytes <= 1_048_576
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def eviction_candidate
|
|
247
|
+
return if @entries.length < MAX_OUTPUTS
|
|
248
|
+
entry = @entries.find { |candidate| @eof_outputs.key?(candidate) && !running?(candidate) }
|
|
249
|
+
raise Error, "at most #{MAX_OUTPUTS} task outputs may run at once" unless entry
|
|
250
|
+
entry
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def ensure_closer_capacity!
|
|
254
|
+
reap_closers
|
|
255
|
+
raise Error, "too many task processes are still closing" if @close_threads.length >= MAX_CLOSERS
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def close_later(entry, interrupt: false)
|
|
259
|
+
return @close_threads[entry] if @closed_outputs.key?(entry)
|
|
260
|
+
ensure_closer_capacity!
|
|
261
|
+
thread = Thread.new do
|
|
262
|
+
Thread.current.report_on_exception = false
|
|
263
|
+
terminal = entry.terminal
|
|
264
|
+
begin
|
|
265
|
+
if interrupt && (!terminal.respond_to?(:alive?) || terminal.alive?)
|
|
266
|
+
terminal.signal("INT") if terminal.respond_to?(:signal)
|
|
267
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + STOP_GRACE_SECONDS
|
|
268
|
+
sleep(0.01) while terminal.respond_to?(:alive?) && terminal.alive? &&
|
|
269
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
|
|
270
|
+
end
|
|
271
|
+
ensure
|
|
272
|
+
terminal.close if terminal.respond_to?(:close)
|
|
273
|
+
end
|
|
274
|
+
rescue StandardError => error
|
|
275
|
+
record_close_error(error)
|
|
276
|
+
end
|
|
277
|
+
@close_threads[entry] = thread
|
|
278
|
+
@closed_outputs[entry] = true
|
|
279
|
+
thread
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def close_now(entry)
|
|
283
|
+
entry.terminal.close if entry.terminal.respond_to?(:close)
|
|
284
|
+
rescue StandardError => error
|
|
285
|
+
record_close_error(error)
|
|
286
|
+
ensure
|
|
287
|
+
@closed_outputs[entry] = true
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def record_close_error(error)
|
|
291
|
+
@close_error ||= error
|
|
292
|
+
@report.call(error)
|
|
293
|
+
rescue StandardError => report_error
|
|
294
|
+
@close_error ||= report_error
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def reap_closers
|
|
298
|
+
@close_threads.delete_if do |entry, thread|
|
|
299
|
+
next false unless thread.join(0)
|
|
300
|
+
@sizes&.delete(entry.terminal)
|
|
301
|
+
retained = @entries.any? { |current| current.equal?(entry) }
|
|
302
|
+
if retained
|
|
303
|
+
@eof_outputs[entry] = true unless @blocked_outputs.key?(entry)
|
|
304
|
+
else
|
|
305
|
+
@closed_outputs.delete(entry)
|
|
306
|
+
@eof_outputs.delete(entry)
|
|
307
|
+
@blocked_outputs.delete(entry)
|
|
308
|
+
end
|
|
309
|
+
true
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canopus
|
|
4
|
+
module TestRunner
|
|
5
|
+
# Loads ignore files only for directories the bounded walker reaches.
|
|
6
|
+
class BoundedIgnore
|
|
7
|
+
MAX_FILES = 1_000
|
|
8
|
+
MAX_FILE_BYTES = 256 * 1_024
|
|
9
|
+
MAX_TOTAL_BYTES = 1 << 20
|
|
10
|
+
MAX_LINES = 10_000
|
|
11
|
+
MAX_LINE_BYTES = 4_096
|
|
12
|
+
MAX_GIT_POINTER_BYTES = 4_096
|
|
13
|
+
|
|
14
|
+
def initialize(root, delegate: nil, cancelled: -> { false })
|
|
15
|
+
@root = root
|
|
16
|
+
@delegate = delegate
|
|
17
|
+
@cancelled = cancelled
|
|
18
|
+
@matcher = Thuban::IgnoreMatcher.new.add("/.canopus/trash/\n")
|
|
19
|
+
@loaded = {}
|
|
20
|
+
@files = @bytes = @lines = 0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def ignored?(path, directory: false)
|
|
24
|
+
path = TestRunner.normalize_path(path)
|
|
25
|
+
return true unless path
|
|
26
|
+
return true if @cancelled.call
|
|
27
|
+
return safe_delegate(path, directory) if @delegate
|
|
28
|
+
|
|
29
|
+
bases_for(path).each do |base|
|
|
30
|
+
break if @cancelled.call
|
|
31
|
+
load_base(base)
|
|
32
|
+
end
|
|
33
|
+
return true if @cancelled.call
|
|
34
|
+
|
|
35
|
+
@matcher.ignored?(path, directory: directory)
|
|
36
|
+
rescue ArgumentError, EncodingError
|
|
37
|
+
true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def safe_delegate(path, directory)
|
|
43
|
+
@delegate.ignored?(path, directory: directory) || @matcher.ignored?(path, directory: directory)
|
|
44
|
+
rescue ArgumentError, EncodingError
|
|
45
|
+
true
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def bases_for(path)
|
|
49
|
+
pieces = path.split("/")[0...-1]
|
|
50
|
+
["", *pieces.each_index.map { |index| pieces[0..index].join("/") }]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def load_base(base)
|
|
54
|
+
return if @loaded[base]
|
|
55
|
+
@loaded[base] = true
|
|
56
|
+
load_repository_exclude if base.empty?
|
|
57
|
+
%w[.gitignore .ignore].each do |name|
|
|
58
|
+
break if @cancelled.call
|
|
59
|
+
load_file(File.join(@root, *base.split("/"), name), base, within: @root)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def load_repository_exclude
|
|
64
|
+
common = repository_common_directory
|
|
65
|
+
load_file(File.join(common, "info", "exclude"), "", within: common) if common
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def repository_common_directory
|
|
69
|
+
git = File.join(@root, ".git")
|
|
70
|
+
entry = File.lstat(git)
|
|
71
|
+
return if entry.symlink?
|
|
72
|
+
git_directory = if entry.directory?
|
|
73
|
+
File.realpath(git)
|
|
74
|
+
elsif entry.file? && (pointer = control_file(git, within: @root))&.start_with?("gitdir: ")
|
|
75
|
+
real_directory(File.expand_path(pointer.delete_prefix("gitdir: "), @root))
|
|
76
|
+
end
|
|
77
|
+
return unless git_directory
|
|
78
|
+
|
|
79
|
+
common_file = File.join(git_directory, "commondir")
|
|
80
|
+
common = control_file(common_file, within: git_directory)
|
|
81
|
+
common ? real_directory(File.expand_path(common, git_directory)) : git_directory
|
|
82
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP, Errno::ENOTDIR
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def real_directory(path)
|
|
87
|
+
entry = File.lstat(path)
|
|
88
|
+
return if entry.symlink? || !entry.directory?
|
|
89
|
+
|
|
90
|
+
File.realpath(path)
|
|
91
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP, Errno::ENOTDIR
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def control_file(path, within:)
|
|
96
|
+
entry = File.lstat(path)
|
|
97
|
+
return if entry.symlink? || !entry.file? || entry.size > MAX_GIT_POINTER_BYTES
|
|
98
|
+
absolute = File.realpath(path)
|
|
99
|
+
return unless inside?(absolute, within)
|
|
100
|
+
|
|
101
|
+
source = File.open(absolute, "rb") { |file| file.read(MAX_GIT_POINTER_BYTES + 1) }
|
|
102
|
+
return if source.bytesize > MAX_GIT_POINTER_BYTES || source.include?("\0")
|
|
103
|
+
source = source.force_encoding(Encoding::UTF_8)
|
|
104
|
+
source.strip if source.valid_encoding?
|
|
105
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP, Errno::ENOTDIR, EncodingError
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def load_file(path, base, within:)
|
|
110
|
+
return if @files >= MAX_FILES || @bytes >= MAX_TOTAL_BYTES || @lines >= MAX_LINES
|
|
111
|
+
entry = File.lstat(path)
|
|
112
|
+
return if entry.symlink? || !entry.file? || entry.size > MAX_FILE_BYTES
|
|
113
|
+
absolute = File.realpath(path)
|
|
114
|
+
return unless inside?(absolute, within)
|
|
115
|
+
|
|
116
|
+
available = [MAX_FILE_BYTES, MAX_TOTAL_BYTES - @bytes].min
|
|
117
|
+
source = File.open(absolute, "rb") { |file| file.read(available + 1) }
|
|
118
|
+
return if source.bytesize > available || source.include?("\0")
|
|
119
|
+
source = source.force_encoding(Encoding::UTF_8)
|
|
120
|
+
return unless source.valid_encoding?
|
|
121
|
+
|
|
122
|
+
kept = +""
|
|
123
|
+
source.each_line do |line|
|
|
124
|
+
break if @cancelled.call || @lines >= MAX_LINES
|
|
125
|
+
next if line.bytesize > MAX_LINE_BYTES
|
|
126
|
+
kept << line
|
|
127
|
+
@lines += 1
|
|
128
|
+
end
|
|
129
|
+
@matcher = @matcher.add(kept, base: base) unless kept.empty? || @cancelled.call
|
|
130
|
+
@files += 1
|
|
131
|
+
@bytes += source.bytesize
|
|
132
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP, Errno::ENOTDIR, EncodingError
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def inside?(path, directory)
|
|
137
|
+
directory = File.realpath(directory)
|
|
138
|
+
path == directory || path.start_with?(directory + File::SEPARATOR)
|
|
139
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP, Errno::ENOTDIR
|
|
140
|
+
false
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canopus
|
|
4
|
+
module TestRunner
|
|
5
|
+
class Discovery
|
|
6
|
+
MAX_FILES = 50_000
|
|
7
|
+
MAX_TEST_FILES = 2_000
|
|
8
|
+
MAX_FILE_BYTES = 1 << 20
|
|
9
|
+
MAX_TESTS = 10_000
|
|
10
|
+
MAX_DEPTH = 64
|
|
11
|
+
MAX_PATH_BYTES = TestRunner::MAX_PATH_BYTES
|
|
12
|
+
MAX_ADAPTERS = 16
|
|
13
|
+
MAX_NAME_BYTES = 4_096
|
|
14
|
+
MAX_GROUPS = 32
|
|
15
|
+
|
|
16
|
+
attr_reader :root
|
|
17
|
+
|
|
18
|
+
def initialize(root:, ignore: nil, adapters: [Minitest.new, RSpec.new])
|
|
19
|
+
@root = File.realpath(root)
|
|
20
|
+
raise ArgumentError, "test root must be a directory" unless File.directory?(@root)
|
|
21
|
+
raise ArgumentError, "ignore must respond to ignored?" if ignore && !ignore.respond_to?(:ignored?)
|
|
22
|
+
unless adapters.is_a?(Array) && !adapters.empty? && adapters.length <= MAX_ADAPTERS &&
|
|
23
|
+
adapters.all? { |adapter| adapter.respond_to?(:candidate?) && adapter.respond_to?(:discover) }
|
|
24
|
+
raise ArgumentError, "test adapters must support candidate? and discover"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
@ignore, @adapters = ignore, adapters.dup.freeze
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def discover(cancelled: -> { false })
|
|
31
|
+
raise ArgumentError, "cancelled must respond to call" unless cancelled.respond_to?(:call)
|
|
32
|
+
|
|
33
|
+
require "prism"
|
|
34
|
+
tests, files, candidates = [], 0, 0
|
|
35
|
+
ignore = BoundedIgnore.new(root, delegate: @ignore, cancelled: cancelled)
|
|
36
|
+
walker = SafeWalker.new(root, ignore: ignore, hidden: true,
|
|
37
|
+
follow_symlinks: false, max_depth: MAX_DEPTH, cancelled: cancelled)
|
|
38
|
+
walker.each do |relative|
|
|
39
|
+
break if cancelled.call || (files += 1) > MAX_FILES || tests.length >= MAX_TESTS
|
|
40
|
+
relative = TestRunner.normalize_path(relative)
|
|
41
|
+
next unless relative
|
|
42
|
+
|
|
43
|
+
adapters = @adapters.select { |adapter| adapter.candidate?(relative) }
|
|
44
|
+
next if adapters.empty?
|
|
45
|
+
break if (candidates += 1) > MAX_TEST_FILES
|
|
46
|
+
|
|
47
|
+
source = test_source(relative)
|
|
48
|
+
next unless source
|
|
49
|
+
parsed = Prism.parse(source)
|
|
50
|
+
next unless parsed.success?
|
|
51
|
+
|
|
52
|
+
adapters.each do |adapter|
|
|
53
|
+
remaining = MAX_TESTS - tests.length
|
|
54
|
+
break if remaining <= 0 || cancelled.call
|
|
55
|
+
discovered = adapter.discover(parsed.value, path: relative, limit: remaining, cancelled: cancelled)
|
|
56
|
+
next unless discovered.is_a?(Array)
|
|
57
|
+
discovered.first(remaining).each { |test| tests << test if valid_test?(test, relative) }
|
|
58
|
+
end
|
|
59
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP, Errno::ENOTDIR, EncodingError, ArgumentError
|
|
60
|
+
next
|
|
61
|
+
end
|
|
62
|
+
tests.sort_by! { |test| [test.path, test.line, test.column, test.framework.to_s, test.name] }
|
|
63
|
+
tests.freeze
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def valid_test?(test, relative)
|
|
69
|
+
test.is_a?(Test) && test.path == relative && [:minitest, :rspec].include?(test.framework) &&
|
|
70
|
+
valid_name?(test.name) && test.groups.is_a?(Array) && test.groups.length <= MAX_GROUPS &&
|
|
71
|
+
test.groups.all? { |group| valid_name?(group) } &&
|
|
72
|
+
test.line.is_a?(Integer) && test.line.positive? &&
|
|
73
|
+
[test.column, test.offset].all? { |value| value.is_a?(Integer) && value >= 0 } &&
|
|
74
|
+
valid_name?(test.selector)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def valid_name?(value)
|
|
78
|
+
value.is_a?(String) && value.valid_encoding? && !value.include?("\0") && value.bytesize <= MAX_NAME_BYTES
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def test_source(relative)
|
|
82
|
+
candidate = File.join(root, relative)
|
|
83
|
+
entry = File.lstat(candidate)
|
|
84
|
+
return if entry.symlink? || !entry.file? || entry.size > MAX_FILE_BYTES
|
|
85
|
+
|
|
86
|
+
absolute = File.realpath(candidate)
|
|
87
|
+
prefix = root.end_with?(File::SEPARATOR) ? root : root + File::SEPARATOR
|
|
88
|
+
return unless absolute.start_with?(prefix)
|
|
89
|
+
|
|
90
|
+
source = File.open(absolute, "rb") { |file| file.read(MAX_FILE_BYTES + 1) }
|
|
91
|
+
return if source.bytesize > MAX_FILE_BYTES || source.include?("\0")
|
|
92
|
+
source = source.delete_prefix("\xEF\xBB\xBF".b).force_encoding(Encoding::UTF_8)
|
|
93
|
+
source if source.valid_encoding?
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|