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