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,291 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "kochab"
|
|
4
|
+
require_relative "../error"
|
|
5
|
+
require_relative "../settings"
|
|
6
|
+
|
|
7
|
+
module Canopus
|
|
8
|
+
module Debug
|
|
9
|
+
class Configuration
|
|
10
|
+
RELATIVE_PATH = File.join(".canopus", "launch.jsonc")
|
|
11
|
+
MAX_BYTES = 1_048_576
|
|
12
|
+
MAX_CONFIGURATIONS = 128
|
|
13
|
+
MAX_DEPTH = 20
|
|
14
|
+
MAX_COLLECTION_SIZE = 1_024
|
|
15
|
+
MAX_STRING_BYTES = 65_536
|
|
16
|
+
MAX_EXPANDED_STRING_BYTES = 1_048_576
|
|
17
|
+
MAX_TOTAL_EXPANDED_BYTES = 1_048_576
|
|
18
|
+
MAX_ENVIRONMENT_ENTRY_BYTES = 1_048_576
|
|
19
|
+
MAX_ENVIRONMENT_BYTES = 1_048_576
|
|
20
|
+
VARIABLE = /\$\{([^{}]+)\}/
|
|
21
|
+
|
|
22
|
+
attr_reader :root, :configurations
|
|
23
|
+
|
|
24
|
+
def initialize(root:, settings: Settings.new, environment: ENV)
|
|
25
|
+
@root = canonical_root(root)
|
|
26
|
+
@settings = settings
|
|
27
|
+
@environment = snapshot_environment(environment)
|
|
28
|
+
@configurations = load_configurations
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def resolve(configuration, file: nil, line_number: nil, selected_text: nil)
|
|
32
|
+
source = configuration.is_a?(String) ? @configurations.find { |item| item["name"] == configuration } :
|
|
33
|
+
@configurations.find { |item| item.equal?(configuration) }
|
|
34
|
+
raise Error, "unknown debug configuration" unless source
|
|
35
|
+
resolved = expand_value(source, context(file, line_number, selected_text), [MAX_TOTAL_EXPANDED_BYTES])
|
|
36
|
+
%w[program cwd].each do |key|
|
|
37
|
+
resolved[key] = project_path(resolved[key], key) if resolved.key?(key)
|
|
38
|
+
end
|
|
39
|
+
freeze_value(resolved)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def adapter(type, file: nil, line_number: nil, selected_text: nil)
|
|
43
|
+
raise Error, "debug adapter type must be a string" unless type.is_a?(String)
|
|
44
|
+
options = @settings["debug_adapters"]
|
|
45
|
+
options = options[type] if options.is_a?(Hash)
|
|
46
|
+
raise Error, "no debug adapter configured for #{type}" unless options
|
|
47
|
+
freeze_value(expand_value(options, context(file, line_number, selected_text), [MAX_TOTAL_EXPANDED_BYTES]))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def canonical_root(root)
|
|
53
|
+
path = File.realpath(root)
|
|
54
|
+
raise Error, "debug workspace root must be a directory" unless File.directory?(path)
|
|
55
|
+
path.freeze
|
|
56
|
+
rescue SystemCallError, TypeError => error
|
|
57
|
+
raise Error, "invalid debug workspace root: #{error.message}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def load_configurations
|
|
61
|
+
path = File.join(@root, RELATIVE_PATH)
|
|
62
|
+
unless File.exist?(path)
|
|
63
|
+
raise Error, "debug configuration path is a broken symlink" if File.symlink?(path)
|
|
64
|
+
return fallback_configurations
|
|
65
|
+
end
|
|
66
|
+
real_path = File.realpath(path)
|
|
67
|
+
raise Error, "debug configuration is outside the workspace" unless inside_root?(real_path)
|
|
68
|
+
raise Error, "debug configuration must be a regular file" unless File.file?(real_path)
|
|
69
|
+
raise Error, "debug configuration exceeds 1 MiB" if File.size(real_path) > MAX_BYTES
|
|
70
|
+
source = File.binread(real_path, MAX_BYTES + 1).force_encoding(Encoding::UTF_8)
|
|
71
|
+
raise Error, "debug configuration must be valid UTF-8" unless source.valid_encoding?
|
|
72
|
+
raise Error, "debug configuration exceeds 1 MiB" if source.bytesize > MAX_BYTES
|
|
73
|
+
document = Kochab.parse(source)
|
|
74
|
+
raise Error, "invalid debug configuration: #{RELATIVE_PATH}" unless document.valid?
|
|
75
|
+
root = document.value
|
|
76
|
+
raise Error, "debug configuration must be an object" unless root.is_a?(Hash) && root.keys.all? { |key| key.is_a?(String) }
|
|
77
|
+
entries = root.fetch("configurations", [])
|
|
78
|
+
unless entries.is_a?(Array) && entries.length <= MAX_CONFIGURATIONS
|
|
79
|
+
raise Error, "configurations must be an array of at most #{MAX_CONFIGURATIONS} items"
|
|
80
|
+
end
|
|
81
|
+
entries = entries.map { |entry| validate_configuration(entry) }
|
|
82
|
+
names = entries.map { |entry| entry["name"] }
|
|
83
|
+
raise Error, "debug configuration names must be unique" unless names.uniq.length == names.length
|
|
84
|
+
entries.empty? ? fallback_configurations : entries.freeze
|
|
85
|
+
rescue SystemCallError => error
|
|
86
|
+
raise Error, "cannot read debug configuration: #{error.message}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def validate_configuration(entry)
|
|
90
|
+
raise Error, "each debug configuration must be an object" unless entry.is_a?(Hash)
|
|
91
|
+
validate_json(entry)
|
|
92
|
+
bounded_identifier(entry["name"], "debug configuration name", 256)
|
|
93
|
+
type = bounded_identifier(entry["type"], "debug configuration type", 128)
|
|
94
|
+
raise Error, "invalid debug configuration type" unless type.match?(/\A[A-Za-z0-9_.-]+\z/)
|
|
95
|
+
raise Error, "debug request must be launch or attach" unless %w[launch attach].include?(entry["request"])
|
|
96
|
+
%w[program cwd].each do |key|
|
|
97
|
+
raise Error, "debug #{key} must be a string" if entry.key?(key) && !entry[key].is_a?(String)
|
|
98
|
+
end
|
|
99
|
+
if entry.key?("args") && !(entry["args"].is_a?(Array) && entry["args"].length <= 256 && entry["args"].all? { |arg| arg.is_a?(String) })
|
|
100
|
+
raise Error, "debug args must be an array of at most 256 strings"
|
|
101
|
+
end
|
|
102
|
+
if entry.key?("env") && !(entry["env"].is_a?(Hash) && entry["env"].length <= 256 &&
|
|
103
|
+
entry["env"].all? { |key, value| key.is_a?(String) && (value.nil? || value.is_a?(String)) })
|
|
104
|
+
raise Error, "debug env must be an object of at most 256 string values"
|
|
105
|
+
end
|
|
106
|
+
freeze_value(entry)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def validate_json(value, depth = 0)
|
|
110
|
+
raise Error, "debug configuration is nested too deeply" if depth > MAX_DEPTH
|
|
111
|
+
case value
|
|
112
|
+
when Hash
|
|
113
|
+
raise Error, "debug object has too many properties" if value.length > MAX_COLLECTION_SIZE
|
|
114
|
+
value.each do |key, child|
|
|
115
|
+
bounded_identifier(key, "debug option name", 256)
|
|
116
|
+
validate_json(child, depth + 1)
|
|
117
|
+
end
|
|
118
|
+
when Array
|
|
119
|
+
raise Error, "debug array has too many items" if value.length > MAX_COLLECTION_SIZE
|
|
120
|
+
value.each { |child| validate_json(child, depth + 1) }
|
|
121
|
+
when String
|
|
122
|
+
valid_string!(value, "debug string", MAX_STRING_BYTES)
|
|
123
|
+
when Integer, TrueClass, FalseClass, NilClass
|
|
124
|
+
nil
|
|
125
|
+
when Float
|
|
126
|
+
raise Error, "debug numbers must be finite" unless value.finite?
|
|
127
|
+
else
|
|
128
|
+
raise Error, "unsupported debug value"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def bounded_identifier(value, label, maximum)
|
|
133
|
+
valid_string!(value, label, maximum)
|
|
134
|
+
unless value == value.strip && !value.empty? && !value.match?(/[\x00-\x1f\x7f]/)
|
|
135
|
+
raise Error, "invalid #{label}"
|
|
136
|
+
end
|
|
137
|
+
value
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def valid_string!(value, label, maximum)
|
|
141
|
+
unless value.is_a?(String) && value.valid_encoding? &&
|
|
142
|
+
(value.encoding == Encoding::UTF_8 || value.ascii_only?) &&
|
|
143
|
+
value.bytesize <= maximum && !value.include?("\0")
|
|
144
|
+
raise Error, "invalid #{label}"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def context(file, line_number, selected_text)
|
|
149
|
+
{"file" => file && project_path(file, "file"), "lineNumber" => line_number,
|
|
150
|
+
"selectedText" => selected_text}
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def expand_value(value, variables, budget)
|
|
154
|
+
case value
|
|
155
|
+
when Hash
|
|
156
|
+
value.to_h { |key, child| [key.dup, expand_value(child, variables, budget)] }
|
|
157
|
+
when Array
|
|
158
|
+
value.map { |child| expand_value(child, variables, budget) }
|
|
159
|
+
when String
|
|
160
|
+
expand_string(value, variables, budget)
|
|
161
|
+
else
|
|
162
|
+
value
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def expand_string(value, variables, budget)
|
|
167
|
+
expanded = +""
|
|
168
|
+
cursor = 0
|
|
169
|
+
remaining = MAX_EXPANDED_STRING_BYTES
|
|
170
|
+
while (match = VARIABLE.match(value, cursor))
|
|
171
|
+
marker = value.index("${", cursor)
|
|
172
|
+
raise Error, "malformed debug variable" unless marker == match.begin(0)
|
|
173
|
+
remaining = append_expanded!(expanded, value[cursor...marker], budget, remaining)
|
|
174
|
+
name = match[1]
|
|
175
|
+
replacement = if name == "workspaceFolder"
|
|
176
|
+
@root
|
|
177
|
+
elsif variables.key?(name)
|
|
178
|
+
variable(name, variables[name])
|
|
179
|
+
elsif name.start_with?("env:")
|
|
180
|
+
environment_variable(name.delete_prefix("env:"))
|
|
181
|
+
else
|
|
182
|
+
raise Error, "unknown debug variable: #{name}"
|
|
183
|
+
end
|
|
184
|
+
remaining = append_expanded!(expanded, replacement, budget, remaining)
|
|
185
|
+
cursor = match.end(0)
|
|
186
|
+
end
|
|
187
|
+
raise Error, "malformed debug variable" if value.index("${", cursor)
|
|
188
|
+
append_expanded!(expanded, value[cursor..] || "", budget, remaining)
|
|
189
|
+
expanded
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def append_expanded!(output, value, budget, remaining)
|
|
193
|
+
bytes = value.bytesize
|
|
194
|
+
if bytes > remaining || bytes > budget[0]
|
|
195
|
+
raise Error, "expanded debug data exceeds 1 MiB"
|
|
196
|
+
end
|
|
197
|
+
output << value
|
|
198
|
+
budget[0] -= bytes
|
|
199
|
+
remaining - bytes
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def variable(name, value)
|
|
203
|
+
raise Error, "debug variable #{name} is unavailable" if value.nil?
|
|
204
|
+
if name == "lineNumber"
|
|
205
|
+
raise Error, "lineNumber must be a positive integer" unless value.is_a?(Integer) && value.positive? && value <= 2_147_483_647
|
|
206
|
+
return value.to_s
|
|
207
|
+
end
|
|
208
|
+
valid_string!(value, name, MAX_EXPANDED_STRING_BYTES)
|
|
209
|
+
value
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def environment_variable(name)
|
|
213
|
+
raise Error, "invalid environment variable name" unless name.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
|
|
214
|
+
raise Error, "environment variable #{name} is unavailable" unless @environment.key?(name)
|
|
215
|
+
valid_string!(@environment[name], "environment variable #{name}", MAX_EXPANDED_STRING_BYTES)
|
|
216
|
+
@environment[name]
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def project_path(value, label)
|
|
220
|
+
valid_string!(value, label, MAX_EXPANDED_STRING_BYTES)
|
|
221
|
+
raise Error, "debug #{label} must not be empty" if value.empty?
|
|
222
|
+
path = File.expand_path(value, @root)
|
|
223
|
+
if File.exist?(path)
|
|
224
|
+
path = File.realpath(path)
|
|
225
|
+
else
|
|
226
|
+
ancestor, suffix = path, []
|
|
227
|
+
until File.exist?(ancestor) || File.dirname(ancestor) == ancestor
|
|
228
|
+
suffix.unshift(File.basename(ancestor))
|
|
229
|
+
ancestor = File.dirname(ancestor)
|
|
230
|
+
end
|
|
231
|
+
path = File.join(File.realpath(ancestor), *suffix) if File.exist?(ancestor)
|
|
232
|
+
end
|
|
233
|
+
raise Error, "debug #{label} is outside the workspace" unless inside_root?(path)
|
|
234
|
+
cursor = path
|
|
235
|
+
until cursor == @root
|
|
236
|
+
if File.symlink?(cursor)
|
|
237
|
+
raise Error, "debug #{label} is outside the workspace" unless inside_root?(File.realpath(cursor))
|
|
238
|
+
end
|
|
239
|
+
cursor = File.dirname(cursor)
|
|
240
|
+
end
|
|
241
|
+
raise Error, "debug cwd must be a directory" if label == "cwd" && File.exist?(path) && !File.directory?(path)
|
|
242
|
+
raise Error, "debug program must not be a directory" if label == "program" && File.directory?(path)
|
|
243
|
+
path
|
|
244
|
+
rescue SystemCallError => error
|
|
245
|
+
raise Error, "invalid debug #{label}: #{error.message}"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def inside_root?(path)
|
|
249
|
+
path == @root || path.start_with?(@root.end_with?(File::SEPARATOR) ? @root : "#{@root}#{File::SEPARATOR}")
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def snapshot_environment(environment)
|
|
253
|
+
raise Error, "debug environment must provide string pairs" unless environment.respond_to?(:each_pair)
|
|
254
|
+
pairs, total = [], 0
|
|
255
|
+
environment.each_pair do |key, value|
|
|
256
|
+
raise Error, "debug environment has too many entries" if pairs.length >= 4_096
|
|
257
|
+
valid = key.is_a?(String) && value.is_a?(String) && key.valid_encoding? && value.valid_encoding? &&
|
|
258
|
+
(key.encoding == Encoding::UTF_8 || key.ascii_only?) &&
|
|
259
|
+
(value.encoding == Encoding::UTF_8 || value.ascii_only?) &&
|
|
260
|
+
key.bytesize.between?(1, 256) && value.bytesize <= MAX_ENVIRONMENT_ENTRY_BYTES &&
|
|
261
|
+
!key.include?("\0") && !value.include?("\0")
|
|
262
|
+
raise Error, "invalid debug environment entry" unless valid
|
|
263
|
+
total += key.bytesize + value.bytesize
|
|
264
|
+
raise Error, "debug environment exceeds 1 MiB" if total > MAX_ENVIRONMENT_BYTES
|
|
265
|
+
pairs << [key, value]
|
|
266
|
+
end
|
|
267
|
+
pairs.to_h do |key, value|
|
|
268
|
+
[key.dup.freeze, value.dup.freeze]
|
|
269
|
+
end.freeze
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def fallback_configurations
|
|
273
|
+
rakefile = %w[Rakefile rakefile].find do |name|
|
|
274
|
+
path = File.join(@root, name)
|
|
275
|
+
File.file?(path) && inside_root?(File.realpath(path))
|
|
276
|
+
end
|
|
277
|
+
return [].freeze unless rakefile
|
|
278
|
+
[freeze_value({"name" => "Run tests", "type" => "ruby", "request" => "launch",
|
|
279
|
+
"program" => "${workspaceFolder}/bin/rake", "args" => ["test"], "cwd" => "${workspaceFolder}"})].freeze
|
|
280
|
+
rescue SystemCallError
|
|
281
|
+
[].freeze
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def freeze_value(value)
|
|
285
|
+
value.each { |key, child| key.freeze; freeze_value(child) } if value.is_a?(Hash)
|
|
286
|
+
value.each { |child| freeze_value(child) } if value.is_a?(Array)
|
|
287
|
+
value.freeze
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
require "zaniah/ui"
|
|
5
|
+
|
|
6
|
+
module Canopus
|
|
7
|
+
module Debug
|
|
8
|
+
class Console
|
|
9
|
+
ITEM_LIMIT = 1_000
|
|
10
|
+
TEXT_LIMIT = 4_096
|
|
11
|
+
PENDING_LIMIT = 16
|
|
12
|
+
OUTPUT_QUEUE_LIMIT = ITEM_LIMIT
|
|
13
|
+
|
|
14
|
+
attr_reader :tree, :entries, :selected_frame
|
|
15
|
+
|
|
16
|
+
def initialize(post:, request_frame:)
|
|
17
|
+
raise ArgumentError, "debug console callbacks must be callable" unless [post, request_frame].all? { |item| item.respond_to?(:call) }
|
|
18
|
+
|
|
19
|
+
@post, @request_frame = post, request_frame
|
|
20
|
+
@generation = @sequence = 0
|
|
21
|
+
@pending = Set.new
|
|
22
|
+
@output_lock = Mutex.new
|
|
23
|
+
@output_generation = 0
|
|
24
|
+
@output_queue = []
|
|
25
|
+
@output_flush_pending = false
|
|
26
|
+
@entries = [].freeze
|
|
27
|
+
@tree = Zaniah::UI::TreeView.new(nodes, height: 320)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def attach(session)
|
|
31
|
+
reset_requests
|
|
32
|
+
reset_output(session)
|
|
33
|
+
@selected_frame = nil
|
|
34
|
+
@entries = [].freeze
|
|
35
|
+
refresh
|
|
36
|
+
self
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def stopped(session, frame)
|
|
40
|
+
return false unless @session.equal?(session)
|
|
41
|
+
|
|
42
|
+
reset_requests
|
|
43
|
+
@selected_frame = frame
|
|
44
|
+
true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def continued(session)
|
|
48
|
+
return false unless @session.equal?(session)
|
|
49
|
+
|
|
50
|
+
reset_requests
|
|
51
|
+
@selected_frame = nil
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def clear
|
|
56
|
+
detach
|
|
57
|
+
@entries = [].freeze
|
|
58
|
+
refresh
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def detach
|
|
63
|
+
reset_requests
|
|
64
|
+
reset_output(nil)
|
|
65
|
+
@selected_frame = nil
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def output(session, event)
|
|
70
|
+
return false unless output_session?(session)
|
|
71
|
+
rows = output_rows(event)
|
|
72
|
+
return false if rows.empty?
|
|
73
|
+
|
|
74
|
+
schedule = false
|
|
75
|
+
generation = @output_lock.synchronize do
|
|
76
|
+
next unless @session.equal?(session)
|
|
77
|
+
|
|
78
|
+
room = OUTPUT_QUEUE_LIMIT - @output_queue.length
|
|
79
|
+
@output_queue.concat(rows.first(room)) if room.positive?
|
|
80
|
+
unless @output_flush_pending
|
|
81
|
+
@output_flush_pending = schedule = true
|
|
82
|
+
end
|
|
83
|
+
@output_generation
|
|
84
|
+
end
|
|
85
|
+
return false unless generation
|
|
86
|
+
|
|
87
|
+
@post.call { flush_output(session, generation) } if schedule
|
|
88
|
+
true
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def evaluate(expression)
|
|
92
|
+
expression = checked_expression(expression)
|
|
93
|
+
raise Error, "debug session is not stopped" unless active?
|
|
94
|
+
raise Error, "too many pending debug evaluations" if @pending.length >= PENDING_LIMIT
|
|
95
|
+
|
|
96
|
+
generation, session, frame = @generation, @session, @selected_frame
|
|
97
|
+
append("> #{expression}")
|
|
98
|
+
future = session.evaluate(expression, frame_id: frame.id, context: "repl")
|
|
99
|
+
@pending.add(future)
|
|
100
|
+
future.on_complete do |variable, error|
|
|
101
|
+
@post.call do
|
|
102
|
+
@pending.delete(future)
|
|
103
|
+
next unless generation == @generation && @session.equal?(session) && @selected_frame.equal?(frame)
|
|
104
|
+
|
|
105
|
+
if error
|
|
106
|
+
append("Error: #{bounded_text(error.message)}")
|
|
107
|
+
else
|
|
108
|
+
append(result_label(variable))
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
future
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def active? = !!(@session && @selected_frame)
|
|
118
|
+
|
|
119
|
+
def nodes
|
|
120
|
+
return [{id: [:debug_console_status, :empty].freeze, label: "No debug output", value: nil}.freeze] if @entries.empty?
|
|
121
|
+
|
|
122
|
+
@entries.map { |entry| {id: [:debug_console, entry[:id]].freeze, label: entry[:label], value: nil}.freeze }.freeze
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def append(label)
|
|
126
|
+
append_rows([bounded_text(label)])
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def append_rows(rows)
|
|
130
|
+
@sequence += 1
|
|
131
|
+
added = rows.each_with_index.map do |label, index|
|
|
132
|
+
{id: @sequence + index, label: bounded_line(label).freeze}.freeze
|
|
133
|
+
end
|
|
134
|
+
@sequence += added.length - 1
|
|
135
|
+
@entries = [*@entries, *added].last(ITEM_LIMIT).freeze
|
|
136
|
+
refresh
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def refresh
|
|
140
|
+
@tree.replace(nodes)
|
|
141
|
+
@request_frame.call
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def output_rows(event)
|
|
145
|
+
return [] unless event.is_a?(Hash)
|
|
146
|
+
|
|
147
|
+
output = event["output"] || event[:output]
|
|
148
|
+
return [] unless output.is_a?(String)
|
|
149
|
+
output = bounded_output(output)
|
|
150
|
+
return [] if output.empty?
|
|
151
|
+
|
|
152
|
+
category = event["category"] || event[:category]
|
|
153
|
+
category = bounded_text(category) if category.is_a?(String)
|
|
154
|
+
source = event["source"] || event[:source]
|
|
155
|
+
source = source_label(source)
|
|
156
|
+
line = event["line"] || event[:line]
|
|
157
|
+
line = nil unless line.is_a?(Integer) && line.between?(1, 0x7fff_ffff)
|
|
158
|
+
location = source && line ? "#{source}:#{line}" : source
|
|
159
|
+
prefix = [category, location].compact.reject(&:empty?)
|
|
160
|
+
prefix = prefix.empty? ? "" : "[#{prefix.join(" ")}] "
|
|
161
|
+
rows = output.split("\n", -1)
|
|
162
|
+
rows.pop if output.end_with?("\n")
|
|
163
|
+
rows[0] = "#{prefix}#{rows[0]}" unless prefix.empty?
|
|
164
|
+
rows.first(OUTPUT_QUEUE_LIMIT).map { |row| bounded_line(row).freeze }.freeze
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def source_label(source)
|
|
168
|
+
return unless source.is_a?(Hash)
|
|
169
|
+
|
|
170
|
+
value = source["name"] || source[:name]
|
|
171
|
+
unless value.is_a?(String) && !value.empty?
|
|
172
|
+
path = source["path"] || source[:path]
|
|
173
|
+
return unless path.is_a?(String)
|
|
174
|
+
|
|
175
|
+
value = bounded_text(path).tr("\\", "/").split("/").last
|
|
176
|
+
end
|
|
177
|
+
value = bounded_text(value).tr("\\", "/")
|
|
178
|
+
value.include?("/") ? value.split("/").last : value
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def result_label(variable)
|
|
182
|
+
value = variable.respond_to?(:value) ? variable.value : nil
|
|
183
|
+
type = variable.respond_to?(:type) ? variable.type : nil
|
|
184
|
+
text = bounded_text(value)
|
|
185
|
+
type = bounded_text(type) if type
|
|
186
|
+
type && !type.empty? ? "#{text} · #{type}" : text
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def checked_expression(value)
|
|
190
|
+
valid = value.is_a?(String) && value.encoding == Encoding::UTF_8 && value.valid_encoding? &&
|
|
191
|
+
value.bytesize.between?(1, TEXT_LIMIT) && !value.include?("\0") && !value.strip.empty?
|
|
192
|
+
raise ArgumentError, "invalid debug expression" unless valid
|
|
193
|
+
|
|
194
|
+
value.dup.freeze
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def bounded_text(value)
|
|
198
|
+
value.to_s.byteslice(0, TEXT_LIMIT).to_s.dup.force_encoding(Encoding::UTF_8).scrub("")
|
|
199
|
+
.gsub(/[\x00-\x1f\x7f]+/, " ").gsub(/\s+/, " ").strip
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def bounded_output(value)
|
|
203
|
+
value.byteslice(0, TEXT_LIMIT).to_s.dup.force_encoding(Encoding::UTF_8).scrub("")
|
|
204
|
+
.gsub(/\r\n?/, "\n").gsub(/[\x00-\x09\x0b\x0c\x0e-\x1f\x7f]+/, " ")
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def bounded_line(value)
|
|
208
|
+
value.to_s.byteslice(0, TEXT_LIMIT).to_s.dup.force_encoding(Encoding::UTF_8).scrub("")
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def output_session?(session) = @output_lock.synchronize { @session.equal?(session) }
|
|
212
|
+
|
|
213
|
+
def flush_output(session, generation)
|
|
214
|
+
rows = @output_lock.synchronize do
|
|
215
|
+
next unless generation == @output_generation && @session.equal?(session)
|
|
216
|
+
|
|
217
|
+
values, @output_queue = @output_queue, []
|
|
218
|
+
@output_flush_pending = false
|
|
219
|
+
values
|
|
220
|
+
end
|
|
221
|
+
return false unless rows && !rows.empty?
|
|
222
|
+
|
|
223
|
+
append_rows(rows)
|
|
224
|
+
true
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def reset_output(session)
|
|
228
|
+
@output_lock.synchronize do
|
|
229
|
+
@output_generation += 1
|
|
230
|
+
@session = session
|
|
231
|
+
@output_queue = []
|
|
232
|
+
@output_flush_pending = false
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def reset_requests
|
|
237
|
+
@generation += 1
|
|
238
|
+
pending, @pending = @pending, Set.new
|
|
239
|
+
pending.each(&:cancel)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|