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,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