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