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,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
require "digest/sha2"
|
|
5
|
+
|
|
6
|
+
module Canopus
|
|
7
|
+
module TestRunner
|
|
8
|
+
class Execution
|
|
9
|
+
MAX_OUTPUT_BYTES = 256 << 10
|
|
10
|
+
MAX_PARSE_LINES = 10_000
|
|
11
|
+
MAX_FILTER_BYTES = 8_192
|
|
12
|
+
ANSI = /\e(?:\[[0-?]*[ -\/]*[@-~]|\][^\a]*(?:\a|\e\\))/n
|
|
13
|
+
|
|
14
|
+
attr_reader :test, :tests, :output, :generation
|
|
15
|
+
|
|
16
|
+
def self.task(test, root:, tests: [test])
|
|
17
|
+
raise ArgumentError, "discovered test required" unless test.is_a?(Test)
|
|
18
|
+
unless tests.is_a?(Array) && !tests.empty? && tests.length <= Discovery::MAX_TESTS &&
|
|
19
|
+
tests.all? { |candidate| candidate.is_a?(Test) && candidate.framework == test.framework &&
|
|
20
|
+
candidate.path == test.path && candidate.line == test.line }
|
|
21
|
+
raise ArgumentError, "related discovered tests required"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
root = File.realpath(root)
|
|
25
|
+
relative = TestRunner.normalize_path(test.path)
|
|
26
|
+
raise Error, "Test target is invalid" unless relative == test.path
|
|
27
|
+
|
|
28
|
+
candidate = File.join(root, relative)
|
|
29
|
+
raise Error, "Test target is not a file" if File.lstat(candidate).symlink?
|
|
30
|
+
path = File.realpath(candidate)
|
|
31
|
+
prefix = root.end_with?(File::SEPARATOR) ? root : root + File::SEPARATOR
|
|
32
|
+
raise Error, "Test target is outside the workspace" unless path.start_with?(prefix) && File.file?(path)
|
|
33
|
+
|
|
34
|
+
command = case test.framework
|
|
35
|
+
when :minitest then minitest_command(tests)
|
|
36
|
+
when :rspec then rspec_command(tests)
|
|
37
|
+
else raise Error, "Unknown test framework"
|
|
38
|
+
end
|
|
39
|
+
gemfile = File.join(root, "Gemfile")
|
|
40
|
+
bundled = regular_file?(gemfile)
|
|
41
|
+
command = [RbConfig.ruby, "-S", "bundle", "exec", *command] if bundled
|
|
42
|
+
environment = unbundled_environment
|
|
43
|
+
environment["BUNDLE_GEMFILE"] = gemfile if bundled
|
|
44
|
+
{"label" => label(test), "command" => command.freeze, "cwd" => root,
|
|
45
|
+
"env" => environment.freeze,
|
|
46
|
+
"presentation" => {"panel" => "output", "reveal" => "silent"}.freeze}.freeze
|
|
47
|
+
rescue SystemCallError, TypeError => error
|
|
48
|
+
raise Error, "Cannot run test: #{error.message}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def initialize(test, output, generation: nil, tests: [test])
|
|
52
|
+
@test, @tests, @output, @generation = test, tests.dup.freeze, output, generation
|
|
53
|
+
@bytes, @cancelled = +"".b, false
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def append(bytes)
|
|
57
|
+
return if bytes.nil? || bytes.empty?
|
|
58
|
+
|
|
59
|
+
@bytes << bytes.b
|
|
60
|
+
@bytes = @bytes.byteslice(-MAX_OUTPUT_BYTES, MAX_OUTPUT_BYTES) if @bytes.bytesize > MAX_OUTPUT_BYTES
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def cancel = (@cancelled = true)
|
|
65
|
+
|
|
66
|
+
def finish(status)
|
|
67
|
+
return result(:skipped) if @cancelled
|
|
68
|
+
return failure_result unless status&.success?
|
|
69
|
+
|
|
70
|
+
count, skipped = summary
|
|
71
|
+
return failure_result unless count == tests.length
|
|
72
|
+
|
|
73
|
+
skipped == count ? result(:skipped) : result(:success)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def self.minitest_command(tests)
|
|
79
|
+
selectors = tests.map do |test|
|
|
80
|
+
suite = test.groups.join("::")
|
|
81
|
+
raise Error, "Minitest suite is unknown" if suite.empty?
|
|
82
|
+
raise Error, "Minitest selector is unknown" unless test.selector
|
|
83
|
+
|
|
84
|
+
"#{Regexp.escape(suite)}##{Regexp.escape(test.selector)}"
|
|
85
|
+
end
|
|
86
|
+
body = selectors.length == 1 ? selectors.first : "(?:#{selectors.join("|")})"
|
|
87
|
+
filter = "\\A#{body}\\z"
|
|
88
|
+
raise Error, "Minitest selector is too large" if filter.bytesize > MAX_FILTER_BYTES
|
|
89
|
+
|
|
90
|
+
[RbConfig.ruby, "-Itest", tests.first.path, "--name", "/#{filter}/"]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.rspec_command(tests)
|
|
94
|
+
patterns = tests.map do |test|
|
|
95
|
+
raise Error, "RSpec selector is unknown" unless test.selector
|
|
96
|
+
|
|
97
|
+
pattern = "\\A#{Regexp.escape(test.selector)}\\z"
|
|
98
|
+
raise Error, "RSpec selector is too large" if pattern.bytesize > MAX_FILTER_BYTES
|
|
99
|
+
pattern
|
|
100
|
+
end
|
|
101
|
+
size = patterns.sum { |pattern| "--example-matches".bytesize + pattern.bytesize + 2 }
|
|
102
|
+
raise Error, "RSpec selectors are too large" if size > MAX_FILTER_BYTES
|
|
103
|
+
|
|
104
|
+
filters = patterns.flat_map { |pattern| ["--example-matches", pattern] }
|
|
105
|
+
test = tests.first
|
|
106
|
+
[RbConfig.ruby, "-S", "rspec", "#{test.path}:#{test.line}", *filters,
|
|
107
|
+
"--format", "progress", "--no-color"]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def self.label(test)
|
|
111
|
+
name = test.path.encode(Encoding::UTF_8, invalid: :replace, undef: :replace).scrub
|
|
112
|
+
.each_char.map { |character| character.match?(/\s/) ? " " : character }.last(120).join
|
|
113
|
+
digest = Digest::SHA256.hexdigest(test.path).slice(0, 16)
|
|
114
|
+
"Test: #{name} [#{digest}]:#{test.line}:#{test.offset}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def self.regular_file?(path)
|
|
118
|
+
File.lstat(path).file? && !File.lstat(path).symlink?
|
|
119
|
+
rescue Errno::ENOENT, Errno::ENOTDIR
|
|
120
|
+
false
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def self.unbundled_environment
|
|
124
|
+
clean = if defined?(Bundler) && Bundler.respond_to?(:unbundled_env)
|
|
125
|
+
Bundler.unbundled_env
|
|
126
|
+
else
|
|
127
|
+
ENV.to_h.reject { |key, _| key.start_with?("BUNDLE_") }
|
|
128
|
+
end
|
|
129
|
+
(ENV.keys | clean.keys).each_with_object({}) do |key, result|
|
|
130
|
+
result[key] = clean[key] if ENV[key] != clean[key]
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
private_class_method :minitest_command, :rspec_command, :label, :regular_file?, :unbundled_environment
|
|
134
|
+
|
|
135
|
+
def failure_result
|
|
136
|
+
path, line = failure_location || [test.path, test.line]
|
|
137
|
+
result(:failure, path, line, "#{test.name} failed")
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def summary
|
|
141
|
+
summary = nil
|
|
142
|
+
recent_output.each_line do |line|
|
|
143
|
+
line = line.strip
|
|
144
|
+
if test.framework == :minitest &&
|
|
145
|
+
(match = line.match(/\A(\d+)\s+runs?,.*,[ \t]*(\d+)\s+skips?\z/i))
|
|
146
|
+
summary = [match[1].to_i, match[2].to_i]
|
|
147
|
+
elsif test.framework == :rspec &&
|
|
148
|
+
(match = line.match(/\A(\d+)\s+examples?,[ \t]*0\s+failures?(?:,[ \t]*(\d+)\s+pending(?:\s+.*)?)?\z/i))
|
|
149
|
+
summary = [match[1].to_i, match[2].to_s.to_i]
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
summary || [nil, nil]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def failure_location
|
|
156
|
+
relative = test.path.tr("\\", "/")
|
|
157
|
+
candidates = ["./#{relative}", relative]
|
|
158
|
+
recent_output.each_line do |line|
|
|
159
|
+
normalized = line.tr("\\", "/")
|
|
160
|
+
candidates.each do |path|
|
|
161
|
+
cursor = normalized.index("#{path}:")
|
|
162
|
+
next unless cursor
|
|
163
|
+
number = normalized[cursor + path.length + 1, 12].to_s[/\A\d{1,10}/]
|
|
164
|
+
value = number.to_i
|
|
165
|
+
return [relative, value] if value.between?(1, Discovery::MAX_FILE_BYTES + 1)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
nil
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def output_text
|
|
172
|
+
@output_text ||= @bytes.gsub(ANSI, "").encode(Encoding::UTF_8, invalid: :replace, undef: :replace).scrub
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def recent_output
|
|
176
|
+
text = output_text
|
|
177
|
+
bytes, cursor = text.b, text.bytesize
|
|
178
|
+
MAX_PARSE_LINES.times do
|
|
179
|
+
index = cursor > 1 ? bytes.rindex("\n".b, cursor - 2) : nil
|
|
180
|
+
unless index
|
|
181
|
+
cursor = 0
|
|
182
|
+
break
|
|
183
|
+
end
|
|
184
|
+
cursor = index + 1
|
|
185
|
+
end
|
|
186
|
+
text.byteslice(cursor..).force_encoding(Encoding::UTF_8)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def result(status, path = nil, line = nil, message = nil)
|
|
190
|
+
Result.new(status, path, line, message&.byteslice(0, 4_096)&.scrub("")).freeze
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canopus
|
|
4
|
+
module TestRunner
|
|
5
|
+
class Minitest
|
|
6
|
+
MAX_NODES = 100_000
|
|
7
|
+
MAX_GROUP_DEPTH = 32
|
|
8
|
+
MAX_NAME_BYTES = 4_096
|
|
9
|
+
SPEC_GROUPS = %i[describe context].freeze
|
|
10
|
+
SPEC_TESTS = %i[it specify].freeze
|
|
11
|
+
|
|
12
|
+
def candidate?(path)
|
|
13
|
+
TestRunner.framework_for(path) == :minitest
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def discover(root, path:, limit:, cancelled:)
|
|
17
|
+
require "prism"
|
|
18
|
+
path = path.dup.freeze
|
|
19
|
+
tests, visited = [], 0
|
|
20
|
+
stack = [[root, [].freeze, :none, nil]]
|
|
21
|
+
until stack.empty? || tests.length >= limit
|
|
22
|
+
break if cancelled.call
|
|
23
|
+
node, groups, scope, spec_count = stack.pop
|
|
24
|
+
break if (visited += 1) > MAX_NODES
|
|
25
|
+
|
|
26
|
+
case node
|
|
27
|
+
when Prism::ClassNode
|
|
28
|
+
group = name(node.constant_path, rootless: true)
|
|
29
|
+
children = group && groups.length < MAX_GROUP_DEPTH ? [*groups, group].freeze : groups
|
|
30
|
+
class_scope = spec_class?(node) ? :spec_class : :class
|
|
31
|
+
stack << [node.body, children, class_scope, class_scope == :spec_class ? [0] : nil] if node.body
|
|
32
|
+
when Prism::ModuleNode
|
|
33
|
+
group = name(node.constant_path, rootless: true)
|
|
34
|
+
children = group && groups.length < MAX_GROUP_DEPTH ? [*groups, group].freeze : groups
|
|
35
|
+
stack << [node.body, children, :none, nil] if node.body
|
|
36
|
+
when Prism::SingletonClassNode
|
|
37
|
+
next
|
|
38
|
+
when Prism::DefNode
|
|
39
|
+
method_name = name(node.name.to_s)
|
|
40
|
+
if [:class, :spec_class].include?(scope) && !node.receiver && method_name&.start_with?("test_")
|
|
41
|
+
add(tests, path, method_name, groups, node, method_name)
|
|
42
|
+
end
|
|
43
|
+
when Prism::CallNode
|
|
44
|
+
if group_call?(node) && groups.length < MAX_GROUP_DEPTH && (group = group_name(node))
|
|
45
|
+
parent = [:spec, :spec_class].include?(scope) ? groups : []
|
|
46
|
+
stack << [node.block.body, [*parent, group].freeze, :spec, [0]] if node.block.body
|
|
47
|
+
elsif spec_test_call?(node, scope)
|
|
48
|
+
selector = format("test_%04d_%s", spec_count[0] += 1, (test_name = literal_argument_name(node)))
|
|
49
|
+
add(tests, path, test_name, groups, node, selector) if test_name
|
|
50
|
+
elsif class_test_call?(node, scope) && (test_name = literal_argument_name(node))
|
|
51
|
+
add(tests, path, test_name, groups, node, "test_#{test_name.gsub(/\s+/, "_")}")
|
|
52
|
+
else
|
|
53
|
+
push_children(stack, node, groups, scope, spec_count) unless node.block
|
|
54
|
+
end
|
|
55
|
+
else
|
|
56
|
+
push_children(stack, node, groups, scope, spec_count)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
tests.freeze
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def group_call?(node) = SPEC_GROUPS.include?(node.name) && node.block && !node.receiver
|
|
65
|
+
def spec_test_call?(node, scope)
|
|
66
|
+
[:spec, :spec_class].include?(scope) && SPEC_TESTS.include?(node.name) && !node.receiver
|
|
67
|
+
end
|
|
68
|
+
def class_test_call?(node, scope)
|
|
69
|
+
[:class, :spec_class].include?(scope) && node.name == :test && node.block && !node.receiver
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def spec_class?(node)
|
|
73
|
+
superclass = name(node.superclass)
|
|
74
|
+
superclass&.delete_prefix("::") == "Minitest::Spec"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def push_children(stack, node, groups, scope, spec_count)
|
|
78
|
+
node.compact_child_nodes.reverse_each { |child| stack << [child, groups, scope, spec_count] }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def literal_argument_name(node)
|
|
82
|
+
argument = node.arguments&.arguments&.first
|
|
83
|
+
name(argument) if argument.is_a?(Prism::StringNode) || argument.is_a?(Prism::SymbolNode)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def group_name(node)
|
|
87
|
+
arguments = node.arguments&.arguments
|
|
88
|
+
return if !arguments || arguments.empty?
|
|
89
|
+
|
|
90
|
+
parts = arguments.map { |argument| name(argument, rootless: true) }
|
|
91
|
+
parts.join("::").freeze if parts.all?
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def name(node, rootless: false)
|
|
95
|
+
value = case node
|
|
96
|
+
when String then node
|
|
97
|
+
when Prism::StringNode, Prism::SymbolNode then node.unescaped
|
|
98
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode then node.full_name
|
|
99
|
+
end
|
|
100
|
+
return unless value
|
|
101
|
+
|
|
102
|
+
value = value.to_s.encode(Encoding::UTF_8)
|
|
103
|
+
value = value.delete_prefix("::") if rootless
|
|
104
|
+
return unless value.valid_encoding? && !value.include?("\0") && value.bytesize <= MAX_NAME_BYTES
|
|
105
|
+
value.freeze
|
|
106
|
+
rescue EncodingError
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def add(tests, path, name, groups, node, selector)
|
|
111
|
+
tests << Test.new(:minitest, path, name, groups,
|
|
112
|
+
node.location.start_line, node.location.start_column, node.location.start_offset, selector.freeze)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canopus
|
|
4
|
+
module TestRunner
|
|
5
|
+
class RSpec
|
|
6
|
+
MAX_NODES = 100_000
|
|
7
|
+
MAX_GROUP_DEPTH = 32
|
|
8
|
+
MAX_NAME_BYTES = 4_096
|
|
9
|
+
GROUPS = %i[describe context feature fdescribe xdescribe fcontext xcontext].freeze
|
|
10
|
+
EXAMPLES = %i[it specify example scenario fit focus fspecify xit xspecify xexample fexample fscenario xscenario skip].freeze
|
|
11
|
+
|
|
12
|
+
def candidate?(path)
|
|
13
|
+
TestRunner.framework_for(path) == :rspec
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def discover(root, path:, limit:, cancelled:)
|
|
17
|
+
require "prism"
|
|
18
|
+
path = path.dup.freeze
|
|
19
|
+
tests, visited = [], 0
|
|
20
|
+
stack = [[root, [].freeze, "".freeze, false]]
|
|
21
|
+
until stack.empty? || tests.length >= limit
|
|
22
|
+
break if cancelled.call
|
|
23
|
+
node, groups, full_description, parent_module = stack.pop
|
|
24
|
+
break if (visited += 1) > MAX_NODES
|
|
25
|
+
|
|
26
|
+
if node.is_a?(Prism::DefNode)
|
|
27
|
+
next
|
|
28
|
+
elsif node.is_a?(Prism::CallNode)
|
|
29
|
+
if group_call?(node) && groups.length < MAX_GROUP_DEPTH && (details = group_name(node))
|
|
30
|
+
group, last_module = details
|
|
31
|
+
separator = !full_description.empty? && !(parent_module && group.match?(/\A(?:#|::|\.)/)) ? " " : ""
|
|
32
|
+
nested_description = "#{full_description}#{separator}#{group}".freeze
|
|
33
|
+
stack << [node.block.body, [*groups, group].freeze, nested_description, last_module] if node.block.body
|
|
34
|
+
next
|
|
35
|
+
elsif !groups.empty? && EXAMPLES.include?(node.name) && !node.receiver && (name = literal_name(node.arguments&.arguments&.first))
|
|
36
|
+
add(tests, path, name, groups, full_description, node)
|
|
37
|
+
next
|
|
38
|
+
end
|
|
39
|
+
next if node.block
|
|
40
|
+
end
|
|
41
|
+
node.compact_child_nodes.reverse_each do |child|
|
|
42
|
+
stack << [child, groups, full_description, parent_module]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
tests.freeze
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def group_call?(node)
|
|
51
|
+
return false unless GROUPS.include?(node.name) && node.block
|
|
52
|
+
return true unless node.receiver
|
|
53
|
+
|
|
54
|
+
(node.receiver.is_a?(Prism::ConstantReadNode) || node.receiver.is_a?(Prism::ConstantPathNode)) &&
|
|
55
|
+
node.receiver.full_name.delete_prefix("::") == "RSpec"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def group_name(node)
|
|
59
|
+
arguments = node.arguments&.arguments&.dup
|
|
60
|
+
return if !arguments || arguments.empty?
|
|
61
|
+
|
|
62
|
+
arguments.pop if arguments.last.is_a?(Prism::KeywordHashNode) || arguments.last.is_a?(Prism::HashNode)
|
|
63
|
+
arguments.pop while arguments.length > 1 && arguments.last.is_a?(Prism::SymbolNode)
|
|
64
|
+
return unless arguments.length.between?(1, 2)
|
|
65
|
+
|
|
66
|
+
first = name(arguments.first, rootless: true)
|
|
67
|
+
second = name(arguments[1], rootless: true) if arguments.length == 2
|
|
68
|
+
return unless first && !first.empty? && (arguments.length == 1 || second && !second.empty?)
|
|
69
|
+
return [first, constant?(arguments.first)].freeze if arguments.length == 1
|
|
70
|
+
|
|
71
|
+
separator = constant?(arguments.first) && second.match?(/\A(?:#|::|\.)/) ? "" : " "
|
|
72
|
+
["#{first}#{separator}#{second}".freeze, constant?(arguments.last)].freeze
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def literal_name(node)
|
|
76
|
+
name(node) if node.is_a?(Prism::StringNode) || node.is_a?(Prism::SymbolNode)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def constant?(node) = node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
|
|
80
|
+
|
|
81
|
+
def name(node, rootless: false)
|
|
82
|
+
value = case node
|
|
83
|
+
when Prism::StringNode, Prism::SymbolNode then node.unescaped
|
|
84
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode then node.full_name
|
|
85
|
+
end
|
|
86
|
+
return unless value
|
|
87
|
+
|
|
88
|
+
value = value.to_s.encode(Encoding::UTF_8)
|
|
89
|
+
value = value.delete_prefix("::") if rootless
|
|
90
|
+
return unless value.valid_encoding? && !value.include?("\0") && value.bytesize <= MAX_NAME_BYTES
|
|
91
|
+
value.freeze
|
|
92
|
+
rescue EncodingError
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def add(tests, path, name, groups, full_description, node)
|
|
97
|
+
selector = "#{full_description} #{name}".freeze
|
|
98
|
+
tests << Test.new(:rspec, path, name, groups,
|
|
99
|
+
node.location.start_line, node.location.start_column, node.location.start_offset, selector)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canopus
|
|
4
|
+
module TestRunner
|
|
5
|
+
# Rejects byte-invalid names before Alkaid computes depth or consults ignores.
|
|
6
|
+
class SafeWalker < Alkaid::Walker
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def walk(directory, visited, &block)
|
|
10
|
+
return if @cancelled&.call
|
|
11
|
+
|
|
12
|
+
absolute_directory = absolute(directory)
|
|
13
|
+
stat = File.stat(absolute_directory)
|
|
14
|
+
return unless visited.add?([stat.dev, stat.ino])
|
|
15
|
+
|
|
16
|
+
Dir.children(absolute_directory).sort.each do |name|
|
|
17
|
+
return if @cancelled&.call
|
|
18
|
+
next if name == ".git" || (!@hidden && name.start_with?("."))
|
|
19
|
+
|
|
20
|
+
relative = directory.empty? ? name : File.join(directory, name)
|
|
21
|
+
normalized = File::ALT_SEPARATOR ? relative.tr(File::ALT_SEPARATOR, "/") : relative
|
|
22
|
+
normalized = TestRunner.normalize_path(normalized)
|
|
23
|
+
next unless normalized
|
|
24
|
+
|
|
25
|
+
depth = normalized.count("/") + 1
|
|
26
|
+
next if @max_depth && depth > @max_depth
|
|
27
|
+
|
|
28
|
+
visit(relative, normalized, depth, visited, &block)
|
|
29
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP, Errno::ENOTDIR, ArgumentError, EncodingError
|
|
30
|
+
next
|
|
31
|
+
end
|
|
32
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP, Errno::ENOTDIR
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "data_compat"
|
|
4
|
+
require "thuban"
|
|
5
|
+
alkaid_path = ENV["ALKAID_PATH"]
|
|
6
|
+
alkaid_root = File.expand_path("../..", __dir__)
|
|
7
|
+
alkaid_path ? require(File.expand_path("lib/alkaid", File.expand_path(alkaid_path, alkaid_root))) : require("alkaid")
|
|
8
|
+
|
|
9
|
+
module Canopus
|
|
10
|
+
module TestRunner
|
|
11
|
+
Test = Data.define(:framework, :path, :name, :groups, :line, :column, :offset, :selector)
|
|
12
|
+
Result = Data.define(:status, :path, :line, :message)
|
|
13
|
+
MAX_PATH_BYTES = 16_384
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def normalize_path(path)
|
|
17
|
+
return unless path.is_a?(String) && path.bytesize <= MAX_PATH_BYTES && !path.include?("\0")
|
|
18
|
+
|
|
19
|
+
value = path.encode(Encoding::UTF_8)
|
|
20
|
+
value = value.tr(File::ALT_SEPARATOR, "/") if File::ALT_SEPARATOR
|
|
21
|
+
return unless value.valid_encoding? && !value.start_with?("/")
|
|
22
|
+
return if value.split("/").any? { |part| part.empty? || part == "." || part == ".." }
|
|
23
|
+
|
|
24
|
+
value.freeze
|
|
25
|
+
rescue EncodingError
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def framework_for(path)
|
|
30
|
+
path = normalize_path(path)
|
|
31
|
+
return unless path&.end_with?(".rb")
|
|
32
|
+
|
|
33
|
+
name = File.basename(path)
|
|
34
|
+
return :rspec if name.end_with?("_spec.rb")
|
|
35
|
+
return :minitest if name.end_with?("_test.rb") || name.start_with?("test_")
|
|
36
|
+
|
|
37
|
+
path.split("/")[0...-1].reverse_each do |directory|
|
|
38
|
+
return :minitest if directory == "test"
|
|
39
|
+
return :rspec if directory == "spec"
|
|
40
|
+
end
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
require_relative "test_runner/bounded_ignore"
|
|
48
|
+
require_relative "test_runner/safe_walker"
|
|
49
|
+
require_relative "test_runner/minitest"
|
|
50
|
+
require_relative "test_runner/rspec"
|
|
51
|
+
require_relative "test_runner/discovery"
|
|
52
|
+
require_relative "test_runner/execution"
|
|
53
|
+
|
|
54
|
+
Canopus::TestRunner.private_constant :SafeWalker
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "rexml/document"
|
|
5
|
+
|
|
6
|
+
module Canopus
|
|
7
|
+
class Theme
|
|
8
|
+
module Import
|
|
9
|
+
COLOR_KEYS = {
|
|
10
|
+
"editor.background" => :background, "editor.foreground" => :foreground,
|
|
11
|
+
"sideBar.background" => :panel, "panel.background" => :panel,
|
|
12
|
+
"panel.border" => :border, "editorGroup.border" => :border,
|
|
13
|
+
"tab.activeBackground" => :active_tab, "descriptionForeground" => :muted,
|
|
14
|
+
"focusBorder" => :accent, "editor.selectionBackground" => :selection,
|
|
15
|
+
"editorCursor.foreground" => :cursor, "editor.lineHighlightBackground" => :current_line,
|
|
16
|
+
"statusBar.background" => :status, "errorForeground" => :error,
|
|
17
|
+
"editorError.foreground" => :"diagnostic.error", "problemsErrorIcon.foreground" => :"diagnostic.error",
|
|
18
|
+
"editorWarning.foreground" => :"diagnostic.warning", "problemsWarningIcon.foreground" => :"diagnostic.warning",
|
|
19
|
+
"editorInfo.foreground" => :"diagnostic.information", "problemsInfoIcon.foreground" => :"diagnostic.information",
|
|
20
|
+
"editorHint.foreground" => :"diagnostic.hint", "editorIndentGuide.background" => :"indent.guide",
|
|
21
|
+
"editorIndentGuide.activeBackground" => :"indent.guide.active",
|
|
22
|
+
"editorBracketHighlight.foreground1" => :"bracket.1", "editorBracketHighlight.foreground2" => :"bracket.2",
|
|
23
|
+
"editorBracketHighlight.foreground3" => :"bracket.3", "editorBracketHighlight.foreground4" => :"bracket.4",
|
|
24
|
+
"editorBracketHighlight.foreground5" => :"bracket.5", "editorBracketHighlight.foreground6" => :"bracket.6",
|
|
25
|
+
"editorIndentGuide.background1" => :"indent.guide", "editorIndentGuide.activeBackground1" => :"indent.guide.active"
|
|
26
|
+
}.freeze
|
|
27
|
+
TOKEN_KEYS = {
|
|
28
|
+
"comment" => "Comment", "keyword" => "Keyword", "entity.name.type" => "Name.Class", "entity.name.class" => "Name.Class",
|
|
29
|
+
"entity.name.function" => "Name.Function", "string" => "Literal.String",
|
|
30
|
+
"constant.numeric" => "Literal.Number", "keyword.operator" => "Operator",
|
|
31
|
+
"markup.heading" => "Generic.Heading", "invalid" => "Error"
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
def vscode(data, warnings: nil)
|
|
37
|
+
colors = map_colors(data.fetch("colors", {}), warnings)
|
|
38
|
+
syntax = {}
|
|
39
|
+
Array(data["tokenColors"]).each do |entry|
|
|
40
|
+
next unless entry.is_a?(Hash)
|
|
41
|
+
|
|
42
|
+
settings = entry["settings"]
|
|
43
|
+
color = settings.is_a?(Hash) && settings["foreground"]
|
|
44
|
+
next unless color
|
|
45
|
+
scopes = entry["scope"].to_s.split(/[,;]/).map(&:strip)
|
|
46
|
+
scopes.each do |scope|
|
|
47
|
+
key = TOKEN_KEYS.keys.find { |prefix| scope == prefix || scope.start_with?(prefix + ".") }
|
|
48
|
+
if key
|
|
49
|
+
syntax[TOKEN_KEYS.fetch(key)] = {"color" => color}
|
|
50
|
+
elsif warnings
|
|
51
|
+
warnings << "unsupported token scope: #{scope}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
new_theme(data["name"], colors, syntax)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def tm_theme(xml, warnings: nil)
|
|
59
|
+
values = plist(REXML::Document.new(xml).root.elements[1])
|
|
60
|
+
colors = map_colors(values.fetch("colors", {}), warnings)
|
|
61
|
+
syntax = {}
|
|
62
|
+
Array(values["settings"]).each do |entry|
|
|
63
|
+
next unless entry.is_a?(Hash)
|
|
64
|
+
|
|
65
|
+
settings = entry["settings"]
|
|
66
|
+
if entry["scope"].to_s.empty? && settings.is_a?(Hash)
|
|
67
|
+
colors[:foreground] ||= settings["foreground"] if settings["foreground"].is_a?(String)
|
|
68
|
+
colors[:background] ||= settings["background"] if settings["background"].is_a?(String)
|
|
69
|
+
colors[:cursor] ||= settings["caret"] if settings["caret"].is_a?(String)
|
|
70
|
+
next
|
|
71
|
+
end
|
|
72
|
+
color = settings.is_a?(Hash) && settings["foreground"]
|
|
73
|
+
next unless color
|
|
74
|
+
scope = entry["scope"].to_s
|
|
75
|
+
key = TOKEN_KEYS.keys.find { |prefix| scope == prefix || scope.start_with?(prefix + ".") }
|
|
76
|
+
if key
|
|
77
|
+
syntax[TOKEN_KEYS.fetch(key)] = {"color" => color}
|
|
78
|
+
elsif warnings
|
|
79
|
+
warnings << "unsupported token scope: #{scope}"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
new_theme(values["name"], colors, syntax)
|
|
83
|
+
rescue REXML::ParseException => error
|
|
84
|
+
raise Canopus::Error, "invalid tmTheme: #{error.message}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def load(path, warnings: nil)
|
|
88
|
+
raise Canopus::Error, "theme exceeds 1 MiB" if File.size(path) > 1_048_576
|
|
89
|
+
|
|
90
|
+
source = File.read(path)
|
|
91
|
+
if File.extname(path).downcase == ".tmtheme"
|
|
92
|
+
tm_theme(source, warnings: warnings)
|
|
93
|
+
else
|
|
94
|
+
data = JSON.parse(source)
|
|
95
|
+
vscode(data, warnings: warnings)
|
|
96
|
+
end
|
|
97
|
+
rescue JSON::ParserError => error
|
|
98
|
+
raise Canopus::Error, "invalid theme JSON: #{error.message}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def map_colors(values, warnings)
|
|
102
|
+
return {} unless values.is_a?(Hash)
|
|
103
|
+
|
|
104
|
+
values.each_with_object({}) do |(key, value), result|
|
|
105
|
+
mapped = COLOR_KEYS[key]
|
|
106
|
+
if mapped && value.is_a?(String)
|
|
107
|
+
result[mapped] = value
|
|
108
|
+
elsif warnings
|
|
109
|
+
warnings << "unsupported theme color: #{key}"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def new_theme(name, colors, syntax)
|
|
115
|
+
Canopus::Theme.new(name: name.to_s.empty? ? "Imported" : name, colors: colors, syntax: syntax)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def plist(element)
|
|
119
|
+
return {} unless element
|
|
120
|
+
return element.elements.each_with_object({}) do |child, result|
|
|
121
|
+
next unless child.name == "key"
|
|
122
|
+
value = child.next_element
|
|
123
|
+
result[child.text.to_s] = plist(value)
|
|
124
|
+
end if element.name == "dict"
|
|
125
|
+
return element.elements.map { |child| plist(child) } if element.name == "array"
|
|
126
|
+
return element.text.to_s if %w[string integer real].include?(element.name)
|
|
127
|
+
element.name == "true"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|