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,356 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "kochab"
4
+ require_relative "../error"
5
+
6
+ module Canopus
7
+ module Task
8
+ class Configuration
9
+ RELATIVE_PATH = File.join(".canopus", "tasks.jsonc")
10
+ MAX_BYTES = 1_048_576
11
+ MAX_TASKS = 128
12
+ MAX_PROBLEM_MATCHERS = 128
13
+ MAX_DEPTH = 20
14
+ MAX_COLLECTION_SIZE = 1_024
15
+ MAX_STRING_BYTES = 65_536
16
+ MAX_EXPANDED_BYTES = 1_048_576
17
+ MAX_ENVIRONMENT_BYTES = 1_048_576
18
+ VARIABLE = /\$\{([^{}]+)\}/
19
+
20
+ attr_reader :root, :tasks, :problem_matchers
21
+
22
+ def initialize(root:, environment: ENV)
23
+ @root = canonical_root(root)
24
+ @environment = snapshot_environment(environment)
25
+ @problem_matchers = {}.freeze
26
+ @tasks = load_tasks
27
+ end
28
+
29
+ def problem_matcher(name) = @problem_matchers[name]
30
+
31
+ def resolve(task, file: nil, line_number: nil, selected_text: nil)
32
+ source = task.is_a?(String) ? @tasks.find { |item| item["label"] == task } :
33
+ @tasks.find { |item| item.equal?(task) }
34
+ raise Error, "unknown task" unless source
35
+
36
+ variables = context(file, line_number, selected_text)
37
+ budget = [MAX_EXPANDED_BYTES]
38
+ labels = @tasks.map do |entry|
39
+ expand_string(entry.fetch("label"), variables, budget).tap do |label|
40
+ bounded_identifier(label, "expanded task label", 256)
41
+ end
42
+ end
43
+ raise Error, "expanded task labels must be unique" unless labels.uniq.length == labels.length
44
+
45
+ resolved = expand_value(source, variables, budget)
46
+ bounded_identifier(resolved.fetch("label"), "expanded task label", 256)
47
+ raise Error, "task executable must not be empty" if resolved.fetch("command").first.empty?
48
+ resolved["cwd"] = project_path(resolved.fetch("cwd", @root), "cwd")
49
+ raise Error, "task cwd must be a directory" unless File.directory?(resolved["cwd"])
50
+ freeze_value(resolved)
51
+ end
52
+
53
+ private
54
+
55
+ def canonical_root(root)
56
+ path = File.realpath(root)
57
+ raise Error, "task workspace root must be a directory" unless File.directory?(path)
58
+
59
+ path.freeze
60
+ rescue SystemCallError, TypeError => error
61
+ raise Error, "invalid task workspace root: #{error.message}"
62
+ end
63
+
64
+ def load_tasks
65
+ path = File.join(@root, RELATIVE_PATH)
66
+ unless File.exist?(path)
67
+ raise Error, "task configuration path is a broken symlink" if File.symlink?(path)
68
+ return [].freeze
69
+ end
70
+ real_path = File.realpath(path)
71
+ raise Error, "task configuration is outside the workspace" unless inside_root?(real_path)
72
+ raise Error, "task configuration must be a regular file" unless File.file?(real_path)
73
+ raise Error, "task configuration exceeds 1 MiB" if File.size(real_path) > MAX_BYTES
74
+
75
+ source = File.binread(real_path, MAX_BYTES + 1).force_encoding(Encoding::UTF_8)
76
+ raise Error, "task configuration must be valid UTF-8" unless source.valid_encoding?
77
+ raise Error, "task configuration exceeds 1 MiB" if source.bytesize > MAX_BYTES
78
+ document = Kochab.parse(source)
79
+ raise Error, "invalid task configuration: #{RELATIVE_PATH}" unless document.valid?
80
+ root = document.value
81
+ raise Error, "task configuration must be an object" unless root.is_a?(Hash) && root.keys.all? { |key| key.is_a?(String) }
82
+ @problem_matchers = validate_problem_matchers(root.fetch("problem_matchers", {}))
83
+ entries = root.fetch("tasks", [])
84
+ unless entries.is_a?(Array) && entries.length <= MAX_TASKS
85
+ raise Error, "tasks must be an array of at most #{MAX_TASKS} items"
86
+ end
87
+ entries = entries.map { |entry| validate_task(entry) }
88
+ labels = entries.map { |entry| entry["label"] }
89
+ raise Error, "task labels must be unique" unless labels.uniq.length == labels.length
90
+
91
+ entries.freeze
92
+ rescue SystemCallError => error
93
+ raise Error, "cannot read task configuration: #{error.message}"
94
+ end
95
+
96
+ def validate_problem_matchers(value)
97
+ unless value.is_a?(Hash) && value.length <= MAX_PROBLEM_MATCHERS && value.keys.all? { |name| name.is_a?(String) }
98
+ raise Error, "problem_matchers must be an object of at most #{MAX_PROBLEM_MATCHERS} items"
99
+ end
100
+ value.to_h do |name, definition|
101
+ bounded_identifier(name, "problem matcher name", 256)
102
+ [name.dup.freeze, validate_problem_matcher(definition)]
103
+ end.freeze
104
+ end
105
+
106
+ def validate_problem_matcher(definition)
107
+ raise Error, "each problem matcher must be an object" unless definition.is_a?(Hash)
108
+ validate_json(definition)
109
+ owner = definition.fetch("owner", "task")
110
+ bounded_identifier(owner, "problem matcher owner", 128)
111
+ patterns = definition["pattern"]
112
+ patterns = [patterns] if patterns.is_a?(Hash)
113
+ unless patterns.is_a?(Array) && patterns.length.between?(1, 8)
114
+ raise Error, "problem matcher pattern must contain 1 to 8 patterns"
115
+ end
116
+ patterns = patterns.map { |pattern| validate_problem_pattern(pattern) }
117
+ unless %w[file line message].all? { |key| patterns.any? { |pattern| pattern[key] } }
118
+ raise Error, "problem matcher patterns require file, line, and message captures"
119
+ end
120
+ location = validate_file_location(definition.fetch("file_location", ["relative", "${workspaceFolder}"]))
121
+ background = validate_problem_background(definition["background"])
122
+ result = definition.merge("owner" => owner.dup.freeze,
123
+ "file_location" => location, "pattern" => definition["pattern"].is_a?(Hash) ? patterns.first : patterns)
124
+ result["background"] = background if background
125
+ freeze_value(result)
126
+ rescue KeyError
127
+ raise Error, "problem matcher pattern is required"
128
+ end
129
+
130
+ def validate_problem_pattern(pattern)
131
+ raise Error, "problem matcher pattern must be an object" unless pattern.is_a?(Hash)
132
+ source = pattern["regexp"]
133
+ valid_string!(source, "problem matcher regexp", 16_384)
134
+ Regexp.new(source, timeout: 0.05)
135
+ %w[file line column end_line end_column severity message].each do |name|
136
+ index = pattern[name]
137
+ unless index.nil? || index.is_a?(Integer) && index.between?(1, 99)
138
+ raise Error, "problem matcher #{name} must be a capture index from 1 to 99"
139
+ end
140
+ end
141
+ freeze_value(pattern.dup)
142
+ rescue RegexpError
143
+ raise Error, "invalid problem matcher regexp"
144
+ end
145
+
146
+ def validate_file_location(value)
147
+ if value == "absolute"
148
+ return ["absolute".freeze, @root].freeze
149
+ end
150
+ unless value.is_a?(Array) && value.length == 2 && value.first == "relative" && value.last.is_a?(String)
151
+ raise Error, "problem matcher file_location must be absolute or [relative, base]"
152
+ end
153
+ base = expand_string(value.last, {}, [MAX_EXPANDED_BYTES])
154
+ base = project_path(base, "problem matcher file location")
155
+ raise Error, "problem matcher file location must be a directory" unless File.directory?(base)
156
+ ["relative".freeze, base.freeze].freeze
157
+ end
158
+
159
+ def validate_problem_background(value)
160
+ return unless value
161
+ raise Error, "problem matcher background must be an object" unless value.is_a?(Hash)
162
+ begins = value["begins_pattern"]
163
+ ends = value["ends_pattern"]
164
+ valid_string!(begins, "problem matcher begins_pattern", 16_384)
165
+ valid_string!(ends, "problem matcher ends_pattern", 16_384)
166
+ unless !value.key?("active_on_start") || [true, false].include?(value["active_on_start"])
167
+ raise Error, "problem matcher active_on_start must be boolean"
168
+ end
169
+ Regexp.new(begins, timeout: 0.05)
170
+ Regexp.new(ends, timeout: 0.05)
171
+ freeze_value(value.merge("active_on_start" => value.fetch("active_on_start", false)))
172
+ rescue RegexpError
173
+ raise Error, "invalid problem matcher background regexp"
174
+ end
175
+
176
+ def validate_task(entry)
177
+ raise Error, "each task must be an object" unless entry.is_a?(Hash)
178
+ validate_json(entry)
179
+ bounded_identifier(entry["label"], "task label", 256)
180
+ command = entry["command"]
181
+ unless command.is_a?(Array) && command.length.between?(1, 256) && command.all? { |part| part.is_a?(String) }
182
+ raise Error, "task command must be an array of 1 to 256 strings"
183
+ end
184
+ raise Error, "task executable must not be empty" if command.first.empty?
185
+ raise Error, "task cwd must be a string" if entry.key?("cwd") && !entry["cwd"].is_a?(String)
186
+ if entry.key?("problem_matcher") && !entry["problem_matcher"].is_a?(String)
187
+ raise Error, "task problem_matcher must be a string"
188
+ end
189
+ presentation = entry.fetch("presentation", {})
190
+ raise Error, "task presentation must be an object" unless presentation.is_a?(Hash)
191
+ panel = presentation.fetch("panel", "output")
192
+ reveal = presentation.fetch("reveal", "always")
193
+ raise Error, "task presentation panel must be output" unless panel == "output"
194
+ raise Error, "invalid task presentation reveal" unless %w[always silent never].include?(reveal)
195
+
196
+ freeze_value(entry.merge("presentation" => presentation.merge("panel" => panel, "reveal" => reveal)))
197
+ end
198
+
199
+ def validate_json(value, depth = 0)
200
+ raise Error, "task configuration is nested too deeply" if depth > MAX_DEPTH
201
+ case value
202
+ when Hash
203
+ raise Error, "task object has too many properties" if value.length > MAX_COLLECTION_SIZE
204
+ value.each do |key, child|
205
+ bounded_identifier(key, "task option name", 256)
206
+ validate_json(child, depth + 1)
207
+ end
208
+ when Array
209
+ raise Error, "task array has too many items" if value.length > MAX_COLLECTION_SIZE
210
+ value.each { |child| validate_json(child, depth + 1) }
211
+ when String
212
+ valid_string!(value, "task string", MAX_STRING_BYTES)
213
+ when Integer, TrueClass, FalseClass, NilClass
214
+ nil
215
+ when Float
216
+ raise Error, "task numbers must be finite" unless value.finite?
217
+ else
218
+ raise Error, "unsupported task value"
219
+ end
220
+ end
221
+
222
+ def bounded_identifier(value, label, maximum)
223
+ valid_string!(value, label, maximum)
224
+ unless value == value.strip && !value.empty? && !value.match?(/[\x00-\x1f\x7f]/)
225
+ raise Error, "invalid #{label}"
226
+ end
227
+ value
228
+ end
229
+
230
+ def valid_string!(value, label, maximum)
231
+ unless value.is_a?(String) && value.valid_encoding? &&
232
+ (value.encoding == Encoding::UTF_8 || value.ascii_only?) &&
233
+ value.bytesize <= maximum && !value.include?("\0")
234
+ raise Error, "invalid #{label}"
235
+ end
236
+ end
237
+
238
+ def context(file, line_number, selected_text)
239
+ {"file" => file && project_path(file, "file"), "lineNumber" => line_number,
240
+ "selectedText" => selected_text}
241
+ end
242
+
243
+ def expand_value(value, variables, budget)
244
+ case value
245
+ when Hash then value.to_h { |key, child| [key.dup, expand_value(child, variables, budget)] }
246
+ when Array then value.map { |child| expand_value(child, variables, budget) }
247
+ when String then expand_string(value, variables, budget)
248
+ else value
249
+ end
250
+ end
251
+
252
+ def expand_string(value, variables, budget)
253
+ expanded = +""
254
+ cursor = 0
255
+ remaining = MAX_EXPANDED_BYTES
256
+ while (match = VARIABLE.match(value, cursor))
257
+ marker = value.index("${", cursor)
258
+ raise Error, "malformed task variable" unless marker == match.begin(0)
259
+ remaining = append_expanded!(expanded, value[cursor...marker], budget, remaining)
260
+ name = match[1]
261
+ replacement = if name == "workspaceFolder"
262
+ @root
263
+ elsif variables.key?(name)
264
+ variable(name, variables[name])
265
+ elsif name.start_with?("env:")
266
+ environment_variable(name.delete_prefix("env:"))
267
+ else
268
+ raise Error, "unknown task variable: #{name}"
269
+ end
270
+ remaining = append_expanded!(expanded, replacement, budget, remaining)
271
+ cursor = match.end(0)
272
+ end
273
+ raise Error, "malformed task variable" if value.index("${", cursor)
274
+ append_expanded!(expanded, value[cursor..] || "", budget, remaining)
275
+ expanded
276
+ end
277
+
278
+ def append_expanded!(output, value, budget, remaining)
279
+ bytes = value.bytesize
280
+ raise Error, "expanded task data exceeds 1 MiB" if bytes > remaining || bytes > budget[0]
281
+
282
+ output << value
283
+ budget[0] -= bytes
284
+ remaining - bytes
285
+ end
286
+
287
+ def variable(name, value)
288
+ raise Error, "task variable #{name} is unavailable" if value.nil?
289
+ if name == "lineNumber"
290
+ unless value.is_a?(Integer) && value.positive? && value <= 2_147_483_647
291
+ raise Error, "lineNumber must be a positive integer"
292
+ end
293
+ return value.to_s
294
+ end
295
+ valid_string!(value, name, MAX_EXPANDED_BYTES)
296
+ value
297
+ end
298
+
299
+ def environment_variable(name)
300
+ raise Error, "invalid environment variable name" unless name.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
301
+ raise Error, "environment variable #{name} is unavailable" unless @environment.key?(name)
302
+ valid_string!(@environment[name], "environment variable #{name}", MAX_EXPANDED_BYTES)
303
+ @environment[name]
304
+ end
305
+
306
+ def project_path(value, label)
307
+ valid_string!(value, label, MAX_EXPANDED_BYTES)
308
+ raise Error, "task #{label} must not be empty" if value.empty?
309
+ path = File.expand_path(value, @root)
310
+ if File.exist?(path)
311
+ path = File.realpath(path)
312
+ else
313
+ ancestor, suffix = path, []
314
+ until File.exist?(ancestor) || File.dirname(ancestor) == ancestor
315
+ suffix.unshift(File.basename(ancestor))
316
+ ancestor = File.dirname(ancestor)
317
+ end
318
+ path = File.join(File.realpath(ancestor), *suffix) if File.exist?(ancestor)
319
+ end
320
+ raise Error, "task #{label} is outside the workspace" unless inside_root?(path)
321
+
322
+ path
323
+ rescue SystemCallError => error
324
+ raise Error, "invalid task #{label}: #{error.message}"
325
+ end
326
+
327
+ def inside_root?(path)
328
+ path == @root || path.start_with?(@root.end_with?(File::SEPARATOR) ? @root : "#{@root}#{File::SEPARATOR}")
329
+ end
330
+
331
+ def snapshot_environment(environment)
332
+ raise Error, "task environment must provide string pairs" unless environment.respond_to?(:each_pair)
333
+ pairs, total = [], 0
334
+ environment.each_pair do |key, value|
335
+ raise Error, "task environment has too many entries" if pairs.length >= 4_096
336
+ valid = key.is_a?(String) && value.is_a?(String) && key.valid_encoding? && value.valid_encoding? &&
337
+ (key.encoding == Encoding::UTF_8 || key.ascii_only?) &&
338
+ (value.encoding == Encoding::UTF_8 || value.ascii_only?) &&
339
+ key.bytesize.between?(1, 256) && value.bytesize <= MAX_EXPANDED_BYTES &&
340
+ !key.include?("\0") && !value.include?("\0")
341
+ raise Error, "invalid task environment entry" unless valid
342
+ total += key.bytesize + value.bytesize
343
+ raise Error, "task environment exceeds 1 MiB" if total > MAX_ENVIRONMENT_BYTES
344
+ pairs << [key, value]
345
+ end
346
+ pairs.to_h { |key, value| [key.dup.freeze, value.dup.freeze] }.freeze
347
+ end
348
+
349
+ def freeze_value(value)
350
+ value.each { |key, child| key.freeze; freeze_value(child) } if value.is_a?(Hash)
351
+ value.each { |child| freeze_value(child) } if value.is_a?(Array)
352
+ value.freeze
353
+ end
354
+ end
355
+ end
356
+ end
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canopus
4
+ module Task
5
+ class ProblemMatcher
6
+ MAX_LINE_BYTES = 65_536
7
+ MAX_PENDING_BYTES = 65_536
8
+ MAX_BUFFER_BYTES = (16 << 20) + MAX_PENDING_BYTES
9
+ MAX_DIAGNOSTICS = 1_000
10
+ MATCH_TIMEOUT_SECONDS = 0.002
11
+ ANSI = /\e(?:\[[0-?]*[ -\/]*[@-~]|\][^\a]*(?:\a|\e\\))/n
12
+ RetryTimeout = Class.new(StandardError)
13
+ private_constant :RetryTimeout
14
+
15
+ def initialize(definition, root:, &on_change)
16
+ @definition = definition
17
+ @root = File.realpath(root)
18
+ patterns = definition.fetch("pattern")
19
+ patterns = [patterns] unless patterns.is_a?(Array)
20
+ @patterns = patterns.map { |pattern| compile(pattern.fetch("regexp")) }
21
+ @watch = definition["background"]
22
+ @begins = compile(@watch.fetch("begins_pattern")) if @watch
23
+ @ends = compile(@watch.fetch("ends_pattern")) if @watch
24
+ @active = !@watch || @watch.fetch("active_on_start", false)
25
+ @on_change = on_change
26
+ @pending = +"".b
27
+ @cursor = 0
28
+ @diagnostics = {}
29
+ @diagnostic_count = 0
30
+ @sequence = []
31
+ @retrying_line = false
32
+ @disabled = @discarding_line = @dirty = false
33
+ rescue SystemCallError, TypeError => error
34
+ raise Error, "invalid problem matcher: #{error.message}"
35
+ end
36
+
37
+ def diagnostics
38
+ @snapshot ||= @diagnostics.to_h { |uri, values| [uri, values.dup.freeze] }.freeze
39
+ end
40
+
41
+ def pending? = !@disabled && !@pending.index("\n", @cursor).nil?
42
+
43
+ def feed(bytes, max_seconds: 0.004)
44
+ raise Error, "problem matcher input must be a string" unless bytes.is_a?(String)
45
+ unless max_seconds.is_a?(Numeric) && max_seconds.finite? && max_seconds >= 0
46
+ raise ArgumentError, "problem matcher budget must be nonnegative and finite"
47
+ end
48
+ return false if @disabled
49
+
50
+ append(bytes.b)
51
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + max_seconds
52
+ while (newline = @pending.index("\n", @cursor))
53
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
54
+ raw = @pending.byteslice(@cursor, newline - @cursor)
55
+ raw = raw.delete_suffix("\r")
56
+ begin
57
+ consume(raw)
58
+ rescue RetryTimeout
59
+ break
60
+ end
61
+ @retrying_line = false
62
+ break if @disabled
63
+ @cursor = newline + 1
64
+ end
65
+ unless pending?
66
+ if @pending.bytesize - @cursor > MAX_PENDING_BYTES
67
+ @pending.clear
68
+ @cursor = 0
69
+ @discarding_line = true
70
+ @sequence.clear
71
+ else
72
+ compact_pending
73
+ end
74
+ end
75
+ notify_changed
76
+ end
77
+
78
+ def finish
79
+ return diagnostics if @disabled
80
+ return diagnostics if pending?
81
+ raw = @pending.byteslice(@cursor..)
82
+ unless raw.nil? || raw.empty? || @discarding_line
83
+ begin
84
+ consume(raw)
85
+ @retrying_line = false
86
+ rescue RetryTimeout
87
+ disable
88
+ end
89
+ end
90
+ @pending.clear
91
+ @cursor = 0
92
+ @sequence.clear
93
+ @discarding_line = false
94
+ notify_changed
95
+ diagnostics
96
+ end
97
+
98
+ private
99
+
100
+ def compile(source) = Regexp.new(source, timeout: MATCH_TIMEOUT_SECONDS)
101
+
102
+ def append(bytes)
103
+ if @discarding_line
104
+ newline = bytes.index("\n")
105
+ return if newline.nil?
106
+ bytes = bytes.byteslice(newline + 1..) || +"".b
107
+ @discarding_line = false
108
+ end
109
+ compact_pending if @cursor.positive? && @pending.bytesize + bytes.bytesize > MAX_BUFFER_BYTES
110
+ raise Error, "problem matcher buffer exceeds 16 MiB" if @pending.bytesize + bytes.bytesize > MAX_BUFFER_BYTES
111
+ @pending << bytes
112
+ end
113
+
114
+ def compact_pending
115
+ return unless @cursor.positive?
116
+ @pending = @pending.byteslice(@cursor..) || +"".b
117
+ @cursor = 0
118
+ end
119
+
120
+ def consume(raw)
121
+ return false if raw.bytesize > MAX_LINE_BYTES
122
+ line = raw.gsub(ANSI, "").force_encoding(Encoding::UTF_8).scrub
123
+ if @watch
124
+ begins = match?(@begins, line)
125
+ return false if @disabled
126
+ if begins
127
+ @active = true
128
+ @sequence.clear
129
+ return clear_diagnostics
130
+ end
131
+ ends = match?(@ends, line)
132
+ return false if @disabled
133
+ if ends
134
+ @active = false
135
+ @sequence.clear
136
+ return false
137
+ end
138
+ end
139
+ return false unless @active
140
+
141
+ pattern_index = @sequence.length
142
+ pattern = @patterns.fetch(pattern_index)
143
+ match = safe_match(pattern, line)
144
+ return false if @disabled
145
+ unless match
146
+ @sequence.clear
147
+ return false if pattern_index.zero?
148
+ pattern_index = 0
149
+ match = safe_match(@patterns.first, line)
150
+ return false if @disabled
151
+ end
152
+ return false unless match
153
+
154
+ source_pattern = @definition.fetch("pattern")
155
+ source_pattern = source_pattern.fetch(pattern_index) if source_pattern.is_a?(Array)
156
+ @sequence << captures(source_pattern, match)
157
+ return false if @sequence.length < @patterns.length
158
+
159
+ fields = @sequence.each_with_object({}) { |values, merged| merged.merge!(values) }
160
+ @sequence.clear
161
+ diagnostic = build(fields)
162
+ return false unless diagnostic
163
+ return false if @diagnostic_count >= MAX_DIAGNOSTICS
164
+
165
+ uri, value = diagnostic
166
+ (@diagnostics[uri] ||= []) << value
167
+ @diagnostic_count += 1
168
+ changed
169
+ end
170
+
171
+ def match?(pattern, line) = !safe_match(pattern, line).nil?
172
+
173
+ def safe_match(pattern, line)
174
+ Canopus.with_regexp_timeout(pattern) { pattern.match(line) }
175
+ rescue Regexp::TimeoutError
176
+ if Canopus::REGEXP_TIMEOUT_COMPAT && !@retrying_line
177
+ @retrying_line = true
178
+ raise RetryTimeout
179
+ end
180
+ disable
181
+ nil
182
+ end
183
+
184
+ def disable
185
+ @disabled = true
186
+ @pending.clear
187
+ @cursor = 0
188
+ @sequence.clear
189
+ @retrying_line = false
190
+ @discarding_line = false
191
+ end
192
+
193
+ def captures(pattern, match)
194
+ %w[file line column end_line end_column severity message].each_with_object({}) do |name, values|
195
+ index = pattern[name]
196
+ values[name] = match[index] if index && match[index]
197
+ end
198
+ end
199
+
200
+ def build(fields)
201
+ path = matched_path(fields["file"])
202
+ line = positive_integer(fields["line"])
203
+ return unless path && line
204
+
205
+ column = positive_integer(fields["column"]) || 1
206
+ end_line = positive_integer(fields["end_line"]) || line
207
+ end_column = positive_integer(fields["end_column"]) || (end_line == line ? column + 1 : 1)
208
+ return if end_line < line || end_line == line && end_column < column
209
+
210
+ message = fields["message"].to_s.strip
211
+ return if message.empty? || message.bytesize > Diagnostics::MESSAGE_LIMIT
212
+ severity = {"error" => 1, "warning" => 2, "info" => 3, "information" => 3, "hint" => 4}
213
+ .fetch(fields["severity"].to_s.downcase, 1)
214
+ owner = @definition.fetch("owner")
215
+ uri = Sadr::Protocol.uri(path)
216
+ start_position = {"line" => line - 1, "character" => column - 1}.freeze
217
+ end_position = {"line" => end_line - 1, "character" => end_column - 1}.freeze
218
+ value = {"range" => {"start" => start_position, "end" => end_position}.freeze,
219
+ "severity" => severity, "source" => owner, "message" => message.freeze}.freeze
220
+ [uri, value]
221
+ rescue Sadr::Error
222
+ nil
223
+ end
224
+
225
+ def matched_path(value)
226
+ return unless value.is_a?(String) && value.valid_encoding? && value.bytesize.between?(1, 4_096) && !value.include?("\0")
227
+ kind, base = @definition.fetch("file_location")
228
+ path = kind == "relative" ? File.expand_path(value, base) : File.expand_path(value)
229
+ return unless File.file?(path)
230
+ path = File.realpath(path)
231
+ return unless path == @root || path.start_with?(@root + File::SEPARATOR)
232
+ path
233
+ rescue SystemCallError
234
+ nil
235
+ end
236
+
237
+ def positive_integer(value)
238
+ return unless value.is_a?(String) && value.match?(/\A\d{1,10}\z/)
239
+ number = value.to_i
240
+ number if number.between?(1, 2_147_483_647)
241
+ end
242
+
243
+ def clear_diagnostics
244
+ return false if @diagnostics.empty?
245
+ @diagnostics.clear
246
+ @diagnostic_count = 0
247
+ changed
248
+ end
249
+
250
+ def changed
251
+ @snapshot = nil
252
+ @dirty = true
253
+ true
254
+ end
255
+
256
+ def notify_changed
257
+ return false unless @dirty
258
+ @dirty = false
259
+ @on_change&.call(self)
260
+ true
261
+ end
262
+ end
263
+ end
264
+ end