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