canopus 0.1.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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +148 -0
- data/docs/adr/001-runtime-gem-components.md +24 -0
- data/docs/adr/002-bounded-diff-engine.md +22 -0
- data/docs/adr/003-background-language-analysis.md +22 -0
- data/docs/adr/004-persistent-display-map.md +23 -0
- data/docs/adr/005-windows-runtime.md +27 -0
- data/docs/adr/README.md +36 -0
- data/docs/distribution.md +27 -0
- data/docs/lsp.md +38 -0
- data/docs/performance.md +40 -0
- data/docs/snippets.md +32 -0
- data/docs/vim.md +24 -0
- data/docs/workspace_edits.md +32 -0
- data/examples/native_smoke.rb +25 -0
- data/examples/plugins/word_count.rb +11 -0
- data/exe/canopus +19 -0
- data/lib/canopus/block_map.rb +19 -0
- data/lib/canopus/buffer.rb +293 -0
- data/lib/canopus/cli.rb +171 -0
- data/lib/canopus/controller.rb +507 -0
- data/lib/canopus/data_compat.rb +25 -0
- data/lib/canopus/display_map/line_builder.rb +54 -0
- data/lib/canopus/display_map/line_set.rb +8 -0
- data/lib/canopus/display_map/pending_line_set.rb +9 -0
- data/lib/canopus/display_map/summary.rb +23 -0
- data/lib/canopus/display_map/worker.rb +89 -0
- data/lib/canopus/display_map.rb +350 -0
- data/lib/canopus/display_point.rb +5 -0
- data/lib/canopus/editor/snippet_expandable.rb +281 -0
- data/lib/canopus/editor.rb +445 -0
- data/lib/canopus/error.rb +5 -0
- data/lib/canopus/fold_map.rb +87 -0
- data/lib/canopus/git/blame.rb +50 -0
- data/lib/canopus/git/commit.rb +7 -0
- data/lib/canopus/git/corrupt_object.rb +7 -0
- data/lib/canopus/git/diff.rb +131 -0
- data/lib/canopus/git/index.rb +94 -0
- data/lib/canopus/git/object_database.rb +45 -0
- data/lib/canopus/git/pack.rb +186 -0
- data/lib/canopus/git/repository.rb +313 -0
- data/lib/canopus/git/status.rb +74 -0
- data/lib/canopus/git/tree_entry.rb +7 -0
- data/lib/canopus/git.rb +15 -0
- data/lib/canopus/icon_theme.rb +43 -0
- data/lib/canopus/language/background_analysis/job.rb +12 -0
- data/lib/canopus/language/background_analysis/scheduler.rb +70 -0
- data/lib/canopus/language/background_analysis.rb +280 -0
- data/lib/canopus/language/definition.rb +7 -0
- data/lib/canopus/language/document.rb +143 -0
- data/lib/canopus/language/symbol.rb +7 -0
- data/lib/canopus/language/syntax_worker.rb +91 -0
- data/lib/canopus/language.rb +42 -0
- data/lib/canopus/lazy_rope.rb +218 -0
- data/lib/canopus/lsp/client.rb +299 -0
- data/lib/canopus/lsp/error.rb +7 -0
- data/lib/canopus/lsp/future/subscription.rb +9 -0
- data/lib/canopus/lsp/future.rb +87 -0
- data/lib/canopus/lsp/protocol.rb +83 -0
- data/lib/canopus/lsp/server_error.rb +13 -0
- data/lib/canopus/lsp/timeout.rb +7 -0
- data/lib/canopus/lsp/transport.rb +123 -0
- data/lib/canopus/lsp.rb +19 -0
- data/lib/canopus/markdown.rb +69 -0
- data/lib/canopus/match_data_compat.rb +17 -0
- data/lib/canopus/multi_buffer.rb +266 -0
- data/lib/canopus/pane.rb +61 -0
- data/lib/canopus/patch/composite.rb +12 -0
- data/lib/canopus/patch/reload.rb +18 -0
- data/lib/canopus/patch.rb +27 -0
- data/lib/canopus/performance_recorder.rb +207 -0
- data/lib/canopus/plugins/api.rb +50 -0
- data/lib/canopus/plugins/isolated_runtime.rb +167 -0
- data/lib/canopus/plugins/local_runtime.rb +25 -0
- data/lib/canopus/plugins/permission_denied.rb +7 -0
- data/lib/canopus/plugins/registry.rb +30 -0
- data/lib/canopus/plugins.rb +11 -0
- data/lib/canopus/project/ignore_matcher.rb +104 -0
- data/lib/canopus/project/search.rb +164 -0
- data/lib/canopus/project/search_worker/cancelled.rb +3 -0
- data/lib/canopus/project/search_worker/runner.rb +9 -0
- data/lib/canopus/project/search_worker.rb +108 -0
- data/lib/canopus/project/tree.rb +48 -0
- data/lib/canopus/project/watcher.rb +59 -0
- data/lib/canopus/project.rb +127 -0
- data/lib/canopus/regexp_compat.rb +30 -0
- data/lib/canopus/save_conflict.rb +5 -0
- data/lib/canopus/selection.rb +11 -0
- data/lib/canopus/settings.rb +118 -0
- data/lib/canopus/snippet/transform.rb +209 -0
- data/lib/canopus/snippet.rb +183 -0
- data/lib/canopus/tab_map.rb +30 -0
- data/lib/canopus/terminal/cell.rb +7 -0
- data/lib/canopus/terminal/grid.rb +321 -0
- data/lib/canopus/terminal/pty.rb +103 -0
- data/lib/canopus/terminal/scrollback.rb +38 -0
- data/lib/canopus/terminal/vt.rb +398 -0
- data/lib/canopus/terminal.rb +13 -0
- data/lib/canopus/theme.rb +43 -0
- data/lib/canopus/version.rb +5 -0
- data/lib/canopus/vim/commandable.rb +148 -0
- data/lib/canopus/vim/motionable.rb +321 -0
- data/lib/canopus/vim/operator_capable.rb +377 -0
- data/lib/canopus/vim/text_object_selectable.rb +141 -0
- data/lib/canopus/vim.rb +426 -0
- data/lib/canopus/workspace/edit/executable.rb +139 -0
- data/lib/canopus/workspace/edit/node.rb +8 -0
- data/lib/canopus/workspace/edit/plan.rb +170 -0
- data/lib/canopus/workspace/edit/resource_preparable.rb +98 -0
- data/lib/canopus/workspace/edit.rb +6 -0
- data/lib/canopus/workspace/file_change_aware.rb +40 -0
- data/lib/canopus/workspace/file_previewable.rb +31 -0
- data/lib/canopus/workspace/git_aware.rb +140 -0
- data/lib/canopus/workspace/language_aware.rb +413 -0
- data/lib/canopus/workspace/language_server_configurable.rb +181 -0
- data/lib/canopus/workspace/project_searchable.rb +208 -0
- data/lib/canopus/workspace/project_tree_editable.rb +82 -0
- data/lib/canopus/workspace/session_persistable.rb +153 -0
- data/lib/canopus/workspace/settings_aware.rb +91 -0
- data/lib/canopus/workspace/view/terminal_presentable.rb +185 -0
- data/lib/canopus/workspace/view.rb +643 -0
- data/lib/canopus/workspace.rb +525 -0
- data/lib/canopus/wrap_map.rb +108 -0
- data/lib/canopus.rb +24 -0
- data/sig/canopus.rbs +375 -0
- data/sig/controller.rbs +55 -0
- data/sig/display_stages.rbs +49 -0
- data/sig/git.rbs +140 -0
- data/sig/lsp.rbs +99 -0
- data/sig/markdown.rbs +11 -0
- data/sig/performance_recorder.rbs +23 -0
- data/sig/search_services.rbs +8 -0
- data/sig/services.rbs +81 -0
- data/sig/snippet.rbs +42 -0
- data/sig/terminal.rbs +125 -0
- data/sig/vim.rbs +27 -0
- data/sig/workspace_edits.rbs +11 -0
- data/sig/workspace_services.rbs +117 -0
- data/tools/certify_snippet_regex.rb +35 -0
- data/tools/check_dependencies.rb +80 -0
- data/tools/native_check.rb +70 -0
- data/tools/package.rb +113 -0
- data/tools/package_test.rb +32 -0
- metadata +314 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Canopus::Patch::Reload
|
|
4
|
+
attr_reader :before, :after
|
|
5
|
+
def initialize(before, after) = (@before, @after = before, after)
|
|
6
|
+
def edits = []
|
|
7
|
+
def map_offset(offset, bias: :right)
|
|
8
|
+
position = offset.clamp(0, after.bytesize)
|
|
9
|
+
begin
|
|
10
|
+
after.point_at(position)
|
|
11
|
+
position
|
|
12
|
+
rescue RangeError
|
|
13
|
+
position -= 1
|
|
14
|
+
retry if position >= 0
|
|
15
|
+
raise
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canopus
|
|
4
|
+
class Patch
|
|
5
|
+
Edit = Data.define(:old_range, :new_range, :old_text, :new_text)
|
|
6
|
+
attr_reader :edits, :before, :after
|
|
7
|
+
def initialize(before, after, changes)
|
|
8
|
+
@before, @after = before, after
|
|
9
|
+
delta = 0
|
|
10
|
+
@edits = changes.sort_by { |range, _| range.begin }.map do |range, text|
|
|
11
|
+
ending = range.end + (range.exclude_end? ? 0 : 1)
|
|
12
|
+
old = range.begin...ending
|
|
13
|
+
start = range.begin + delta
|
|
14
|
+
delta += text.bytesize - (ending - range.begin)
|
|
15
|
+
Edit.new(old, start...(start + text.bytesize), before.byteslice(old).to_s.freeze, text.dup.freeze)
|
|
16
|
+
end.freeze
|
|
17
|
+
end
|
|
18
|
+
def map_offset(offset, bias: :right)
|
|
19
|
+
Denebola::Anchor.new(offset, bias: bias).transform(@edits.map { |e| [e.old_range, e.new_text] }).offset
|
|
20
|
+
end
|
|
21
|
+
def inverse = Patch.new(after, before, edits.map { |e| [e.new_range, e.old_text] })
|
|
22
|
+
def compose(other) = Composite.new([self, other])
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
require_relative "patch/composite"
|
|
27
|
+
require_relative "patch/reload"
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Canopus
|
|
8
|
+
# Installed only for an explicitly profiled window, never on the Window class.
|
|
9
|
+
class PerformanceRecorder
|
|
10
|
+
attr_reader :sampler
|
|
11
|
+
|
|
12
|
+
def initialize(window, trace_allocations: false, sampling: true, capacity: 600, sample_capacity: 2_000, interval: 0.01)
|
|
13
|
+
raise ArgumentError, "profile capacities must be positive integers" unless [capacity, sample_capacity].all? { |n| n.is_a?(Integer) && n.positive? }
|
|
14
|
+
raise ArgumentError, "sampling interval must be positive and finite" unless interval.positive? && interval.finite?
|
|
15
|
+
@window, @trace_allocations = window, trace_allocations
|
|
16
|
+
@sampling = sampling
|
|
17
|
+
@capacity, @sample_capacity, @interval = capacity, sample_capacity, interval
|
|
18
|
+
@frames, @stacks = [], []
|
|
19
|
+
@count = @sample_count = @total_allocations = 0
|
|
20
|
+
@max_frame_ms = 0.0
|
|
21
|
+
@mutex, @wake = Mutex.new, ConditionVariable.new
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def start
|
|
25
|
+
raise ArgumentError, "a profile can only be started once" if @started
|
|
26
|
+
@started = true
|
|
27
|
+
if @trace_allocations
|
|
28
|
+
require "objspace"
|
|
29
|
+
ObjectSpace.trace_object_allocations_start
|
|
30
|
+
@tracing = true
|
|
31
|
+
end
|
|
32
|
+
@running = true
|
|
33
|
+
@started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
34
|
+
profile = self
|
|
35
|
+
@window.singleton_class.prepend(Module.new do
|
|
36
|
+
define_method(:render) do |*args, **kwargs, &block|
|
|
37
|
+
profile.measure { super(*args, **kwargs, &block) }
|
|
38
|
+
end
|
|
39
|
+
end)
|
|
40
|
+
if @sampling
|
|
41
|
+
@sampler = Thread.new { sample_main_thread }
|
|
42
|
+
@sampler.name = "canopus-profile"
|
|
43
|
+
end
|
|
44
|
+
self
|
|
45
|
+
rescue Exception
|
|
46
|
+
stop
|
|
47
|
+
raise
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def measure
|
|
51
|
+
return yield unless @running
|
|
52
|
+
allocated = GC.stat(:total_allocated_objects)
|
|
53
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
54
|
+
completed = false
|
|
55
|
+
begin
|
|
56
|
+
result = yield
|
|
57
|
+
completed = true
|
|
58
|
+
result
|
|
59
|
+
ensure
|
|
60
|
+
elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1_000
|
|
61
|
+
allocations = GC.stat(:total_allocated_objects) - allocated
|
|
62
|
+
draws = @window.device.draw_calls if completed && @window.device.respond_to?(:draw_calls)
|
|
63
|
+
@last = {frame: @count + 1, frame_ms: elapsed, allocations: allocations, draw_calls: draws,
|
|
64
|
+
scale_factor: @window.scale_factor, completed: completed}
|
|
65
|
+
@frames[@count % @capacity] = @last
|
|
66
|
+
@count += 1
|
|
67
|
+
@total_allocations += allocations
|
|
68
|
+
@max_frame_ms = [@max_frame_ms, elapsed].max
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def stop
|
|
73
|
+
@mutex.synchronize { @running = false; @wake.broadcast }
|
|
74
|
+
begin
|
|
75
|
+
@sampler&.join
|
|
76
|
+
ensure
|
|
77
|
+
@stopped_at ||= Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
78
|
+
if @tracing
|
|
79
|
+
ObjectSpace.trace_object_allocations_stop
|
|
80
|
+
@tracing = false
|
|
81
|
+
@allocation_sites = allocation_sites
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
self
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def statistics
|
|
88
|
+
times = @frames.map { |frame| frame[:frame_ms] }.sort
|
|
89
|
+
{frames: @count, retained_frames: @frames.length,
|
|
90
|
+
last_frame_ms: @last&.fetch(:frame_ms), last_allocations: @last&.fetch(:allocations), last_draw_calls: @last&.fetch(:draw_calls),
|
|
91
|
+
median_frame_ms: percentile(times, 0.5), p95_frame_ms: percentile(times, 0.95), max_frame_ms: @max_frame_ms,
|
|
92
|
+
mean_allocations: @count.zero? ? 0 : @total_allocations.fdiv(@count), sampling_samples: @sample_count}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def report
|
|
96
|
+
raise ArgumentError, "stop the profile before saving it" if @running
|
|
97
|
+
{schema_version: 1, kind: "profile", generated_at: Time.now.utc.iso8601,
|
|
98
|
+
context: self.class.context(@window), elapsed_seconds: @started_at ? @stopped_at - @started_at : 0,
|
|
99
|
+
measurement: {
|
|
100
|
+
frame: "Window#render: UI construction, layout, prepaint, paint, GPU submission and synchronous presentation; excludes input/tick/draw callbacks and asynchronous GPU completion",
|
|
101
|
+
allocations: "Process-wide GC total_allocated_objects delta during render, including other threads and sampling overhead; excludes frame-record bookkeeping",
|
|
102
|
+
percentiles: "Retained frame window only; max and mean allocations cover all observed frames",
|
|
103
|
+
timing: "Wall clock, including GC, scheduler waits and synchronous native calls; not GPU timestamps or idle CPU",
|
|
104
|
+
trace_allocations: @trace_allocations
|
|
105
|
+
}, statistics: statistics, frames: @frames.sort_by { |frame| frame[:frame] },
|
|
106
|
+
sampling: {enabled: @sampling, interval_seconds: @interval, max_depth: 32, total_samples: @sample_count, retained_samples: @stacks.length,
|
|
107
|
+
stacks: @stacks.tally.map { |stack, count| {count: count, stack: stack} }.sort_by { |entry| -entry[:count] }},
|
|
108
|
+
allocation_sites: @allocation_sites}
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def write(path) = self.class.write_json(path, report)
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def percentile(sorted, fraction)
|
|
116
|
+
return 0.0 if sorted.empty?
|
|
117
|
+
index = (sorted.length - 1) * fraction
|
|
118
|
+
sorted[index.floor] + (sorted[index.ceil] - sorted[index.floor]) * (index - index.floor)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def sample_main_thread
|
|
122
|
+
loop do
|
|
123
|
+
running = @mutex.synchronize do
|
|
124
|
+
@wake.wait(@mutex, @interval) if @running
|
|
125
|
+
@running
|
|
126
|
+
end
|
|
127
|
+
break unless running
|
|
128
|
+
# These are Ruby stack locations, not a native CPU profiler or object contents.
|
|
129
|
+
stack = (Thread.main.backtrace_locations(0, 32) || []).map { |location| "#{location.path}:#{location.lineno}:in '#{location.base_label}'" }
|
|
130
|
+
@stacks[@sample_count % @sample_capacity] = stack
|
|
131
|
+
@sample_count += 1
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def allocation_sites
|
|
136
|
+
counts, examined = Hash.new(0), 0
|
|
137
|
+
ObjectSpace.each_object do |object|
|
|
138
|
+
examined += 1
|
|
139
|
+
break if examined > 100_000
|
|
140
|
+
file = ObjectSpace.allocation_sourcefile(object)
|
|
141
|
+
next unless file
|
|
142
|
+
site = [file, ObjectSpace.allocation_sourceline(object)]
|
|
143
|
+
counts[site] += 1 if counts.key?(site) || counts.length < 2_000
|
|
144
|
+
end
|
|
145
|
+
{scope: "Traced objects still alive at stop; not all allocated objects. Existing external traces can contribute. At most 100000 live objects examined, 2000 sites retained.",
|
|
146
|
+
examined_objects: [examined, 100_000].min, scan_limit_reached: examined > 100_000,
|
|
147
|
+
sites: counts.sort_by { |_, count| -count }.map { |(file, line), count| {file: file, line: line, live_objects: count} }}
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def self.context(window = nil)
|
|
151
|
+
{ruby: RUBY_DESCRIPTION, engine: RUBY_ENGINE, ruby_version: RUBY_VERSION, platform: RUBY_PLATFORM,
|
|
152
|
+
canopus_version: Canopus::VERSION, zaniah_version: Zaniah::VERSION,
|
|
153
|
+
yjit: !!(defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled?),
|
|
154
|
+
zjit: !!(defined?(RubyVM::ZJIT) && RubyVM::ZJIT.enabled?),
|
|
155
|
+
window: window&.class&.name, renderer: window&.device&.class&.name,
|
|
156
|
+
scale_factor: window&.scale_factor,
|
|
157
|
+
logical_size: window && {width: window.content_size.width, height: window.content_size.height}}
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def self.validate_paths(paths, protected: [])
|
|
161
|
+
reports = paths.compact.map { |path| File.expand_path(path) }
|
|
162
|
+
raise ArgumentError, "diagnostic report paths must be distinct" unless reports.uniq.length == reports.length
|
|
163
|
+
protected = protected.compact.map { |path| File.expand_path(path) }
|
|
164
|
+
reports.each do |path|
|
|
165
|
+
raise ArgumentError, "report directory does not exist: #{File.dirname(path)}" unless File.directory?(File.dirname(path))
|
|
166
|
+
raise ArgumentError, "report path is a directory: #{path}" if File.directory?(path)
|
|
167
|
+
raise ArgumentError, "report path must not be a symlink: #{path}" if File.symlink?(path)
|
|
168
|
+
if protected.any? { |input| input == path || (File.exist?(path) && File.exist?(input) && File.identical?(path, input)) }
|
|
169
|
+
raise ArgumentError, "report path overlaps an input or output file: #{path}"
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
reports
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def self.write_json(path, report)
|
|
176
|
+
target = validate_paths([path]).first
|
|
177
|
+
json = JSON.pretty_generate(report)
|
|
178
|
+
Tempfile.create([".canopus-report-", ".json"], File.dirname(target), mode: File::RDWR, perm: 0o600) do |file|
|
|
179
|
+
file.chmod(0o600)
|
|
180
|
+
file.write(json)
|
|
181
|
+
file.write("\n")
|
|
182
|
+
file.flush
|
|
183
|
+
file.fsync
|
|
184
|
+
file.close
|
|
185
|
+
File.rename(file.path, target)
|
|
186
|
+
end
|
|
187
|
+
target
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def self.write_crash(path, exception, window: nil)
|
|
191
|
+
write_json(path, {schema_version: 1, kind: "crash", generated_at: Time.now.utc.iso8601,
|
|
192
|
+
context: context(window), exception: exception_details(exception),
|
|
193
|
+
privacy: "Local only. Message and backtrace can contain sensitive paths or text; review before sharing. No environment, buffer contents or object dumps are collected."})
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def self.exception_details(exception, depth = 0)
|
|
197
|
+
result = {class: exception.class.name, message: clean_text(exception.message, 8_192),
|
|
198
|
+
backtrace: (exception.backtrace || []).first(100).map { |line| clean_text(line, 2_048) }}
|
|
199
|
+
result[:cause] = exception_details(exception.cause, depth + 1) if exception.cause && depth < 3 && exception.cause != exception
|
|
200
|
+
result
|
|
201
|
+
end
|
|
202
|
+
private_class_method :exception_details
|
|
203
|
+
|
|
204
|
+
def self.clean_text(value, limit) = value.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace).slice(0, limit)
|
|
205
|
+
private_class_method :clean_text
|
|
206
|
+
end
|
|
207
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Canopus
|
|
6
|
+
module Plugins
|
|
7
|
+
class API
|
|
8
|
+
def initialize(workspace, permissions)
|
|
9
|
+
@workspace, @permissions = workspace, permissions
|
|
10
|
+
end
|
|
11
|
+
def permit!(permission)
|
|
12
|
+
raise Canopus::Plugins::PermissionDenied, "plugin requires #{permission}" unless @permissions.include?(permission)
|
|
13
|
+
end
|
|
14
|
+
def text
|
|
15
|
+
permit!("read_buffer")
|
|
16
|
+
@workspace.editor.buffer.text.dup.freeze
|
|
17
|
+
end
|
|
18
|
+
def replace(range, text)
|
|
19
|
+
permit!("edit_buffer")
|
|
20
|
+
@workspace.editor.buffer.edit([[range, text]], kind: :plugin)
|
|
21
|
+
end
|
|
22
|
+
def files
|
|
23
|
+
permit!("read_project")
|
|
24
|
+
@workspace.files.dup.freeze
|
|
25
|
+
end
|
|
26
|
+
def notify(message) = @workspace.message = message.to_s
|
|
27
|
+
def run(command)
|
|
28
|
+
permit!("process")
|
|
29
|
+
raise ArgumentError, "command must be a nonempty argument array" unless command.is_a?(Array) && !command.empty?
|
|
30
|
+
Open3.capture3(*command, chdir: @workspace.root)
|
|
31
|
+
end
|
|
32
|
+
def http_get(url)
|
|
33
|
+
permit!("network")
|
|
34
|
+
require "net/http"
|
|
35
|
+
uri = URI(url)
|
|
36
|
+
raise ArgumentError, "HTTP or HTTPS URL required" unless %w[http https].include?(uri.scheme)
|
|
37
|
+
body = +""
|
|
38
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 5) do |http|
|
|
39
|
+
http.request_get(uri.request_uri) do |response|
|
|
40
|
+
response.read_body do |chunk|
|
|
41
|
+
raise Canopus::Error, "plugin HTTP response exceeds 1 MiB" if body.bytesize + chunk.bytesize > 1 << 20
|
|
42
|
+
body << chunk
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
body
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "rbconfig"
|
|
6
|
+
|
|
7
|
+
# Separate-process execution isolates crashes and accidental global state.
|
|
8
|
+
# It is NOT an OS sandbox: Ruby plugins remain trusted executable code.
|
|
9
|
+
module Canopus
|
|
10
|
+
module Plugins
|
|
11
|
+
class IsolatedRuntime
|
|
12
|
+
WORKER = <<~'RUBY'
|
|
13
|
+
require "json"
|
|
14
|
+
require "stringio"
|
|
15
|
+
require "open3"
|
|
16
|
+
protocol = STDOUT
|
|
17
|
+
$stdout = StringIO.new
|
|
18
|
+
class PluginAPI
|
|
19
|
+
attr_reader :edits, :messages
|
|
20
|
+
def initialize(context, permissions)
|
|
21
|
+
@context, @permissions, @edits, @messages = context, permissions, [], []
|
|
22
|
+
end
|
|
23
|
+
def permit!(name)
|
|
24
|
+
raise "plugin requires #{name}" unless @permissions.include?(name)
|
|
25
|
+
end
|
|
26
|
+
def text
|
|
27
|
+
permit!("read_buffer")
|
|
28
|
+
@context.fetch("text")
|
|
29
|
+
end
|
|
30
|
+
def files
|
|
31
|
+
permit!("read_project")
|
|
32
|
+
@context.fetch("files")
|
|
33
|
+
end
|
|
34
|
+
def replace(range, text)
|
|
35
|
+
permit!("edit_buffer")
|
|
36
|
+
@edits << [range.begin, range.end + (range.exclude_end? ? 0 : 1), text]
|
|
37
|
+
end
|
|
38
|
+
def notify(message) = @messages << message.to_s
|
|
39
|
+
def run(command)
|
|
40
|
+
permit!("process")
|
|
41
|
+
raise "command must be an argument array" unless command.is_a?(Array) && !command.empty?
|
|
42
|
+
Open3.capture3(*command, chdir: @context.fetch("root"))
|
|
43
|
+
end
|
|
44
|
+
def http_get(url)
|
|
45
|
+
permit!("network")
|
|
46
|
+
require "net/http"
|
|
47
|
+
uri = URI(url)
|
|
48
|
+
raise "HTTP or HTTPS URL required" unless %w[http https].include?(uri.scheme)
|
|
49
|
+
body = +""
|
|
50
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 5) do |http|
|
|
51
|
+
http.request_get(uri.request_uri) do |response|
|
|
52
|
+
response.read_body do |chunk|
|
|
53
|
+
raise "plugin HTTP response exceeds 1 MiB" if body.bytesize + chunk.bytesize > 1 << 20
|
|
54
|
+
body << chunk
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
body
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
class PluginDefinition
|
|
62
|
+
attr_reader :actions, :panels, :languages, :servers
|
|
63
|
+
def initialize
|
|
64
|
+
@actions, @panels, @languages, @servers = {}, {}, {}, {}
|
|
65
|
+
end
|
|
66
|
+
def register_action(name, description: name, &block) = @actions[name] = [description, block]
|
|
67
|
+
def register_panel(name, side: :right, &block) = @panels[name] = [side, block]
|
|
68
|
+
def register_language(name, **options) = @languages[name] = options
|
|
69
|
+
def configure_lsp(language, command) = @servers[language] = command
|
|
70
|
+
end
|
|
71
|
+
begin
|
|
72
|
+
bootstrap = JSON.parse(STDIN.gets || raise("missing plugin source"))
|
|
73
|
+
definition = PluginDefinition.new
|
|
74
|
+
definition.instance_eval(bootstrap.fetch("source"), bootstrap.fetch("path"), 1)
|
|
75
|
+
protocol.puts(JSON.generate(actions: definition.actions.transform_values(&:first), panels: definition.panels.transform_values(&:first), languages: definition.languages, servers: definition.servers))
|
|
76
|
+
protocol.flush
|
|
77
|
+
STDIN.each_line do |line|
|
|
78
|
+
begin
|
|
79
|
+
request = JSON.parse(line)
|
|
80
|
+
api = PluginAPI.new(request.fetch("context"), bootstrap.fetch("permissions"))
|
|
81
|
+
table = request["kind"] == "panel" ? definition.panels : definition.actions
|
|
82
|
+
result = table.fetch(request.fetch("name"))[1].call(api)
|
|
83
|
+
protocol.puts(JSON.generate(result: result.is_a?(String) ? result : nil, edits: api.edits, messages: api.messages))
|
|
84
|
+
rescue StandardError, ScriptError => error
|
|
85
|
+
protocol.puts(JSON.generate(error: "#{error.class}: #{error.message}"))
|
|
86
|
+
end
|
|
87
|
+
protocol.flush
|
|
88
|
+
$stdout.truncate(0)
|
|
89
|
+
$stdout.rewind
|
|
90
|
+
end
|
|
91
|
+
rescue StandardError, ScriptError => error
|
|
92
|
+
protocol.puts(JSON.generate(error: "#{error.class}: #{error.message}"))
|
|
93
|
+
protocol.flush
|
|
94
|
+
end
|
|
95
|
+
RUBY
|
|
96
|
+
|
|
97
|
+
def initialize(workspace, source, path, permissions, timeout: 2)
|
|
98
|
+
@workspace, @permissions, @timeout = workspace, permissions, timeout
|
|
99
|
+
@input, @output, @process = Open3.popen2(RbConfig.ruby, "-e", WORKER)
|
|
100
|
+
@input.sync = true
|
|
101
|
+
@output.binmode
|
|
102
|
+
@pending, @lock = +"".b, Mutex.new
|
|
103
|
+
@input.puts(JSON.generate(source: source, path: path, permissions: permissions))
|
|
104
|
+
manifest = response
|
|
105
|
+
manifest.fetch("actions").each do |name, description|
|
|
106
|
+
workspace.register_action(name, description: description) { invoke(:action, name) }
|
|
107
|
+
end
|
|
108
|
+
manifest.fetch("panels").each do |name, side|
|
|
109
|
+
workspace.register_panel(name, side: side.to_sym) { Zaniah::Text.new(invoke(:panel, name).to_s) }
|
|
110
|
+
end
|
|
111
|
+
manifest.fetch("languages").each { |name, options| workspace.register_language(name, **options.transform_keys(&:to_sym)) }
|
|
112
|
+
workspace.settings.merge!("language_servers" => manifest.fetch("servers"))
|
|
113
|
+
rescue StandardError
|
|
114
|
+
close
|
|
115
|
+
raise
|
|
116
|
+
end
|
|
117
|
+
def invoke(kind, name)
|
|
118
|
+
@lock.synchronize do
|
|
119
|
+
buffer = @workspace.editor.buffer
|
|
120
|
+
version = buffer.version
|
|
121
|
+
context = {root: @workspace.root}
|
|
122
|
+
context[:text] = buffer.text if @permissions.include?("read_buffer")
|
|
123
|
+
context[:files] = @workspace.files if @permissions.include?("read_project")
|
|
124
|
+
@input.puts(JSON.generate(kind: kind, name: name, context: context))
|
|
125
|
+
result = response
|
|
126
|
+
edits = result.fetch("edits")
|
|
127
|
+
unless edits.empty?
|
|
128
|
+
raise Canopus::Plugins::PermissionDenied, "plugin requires edit_buffer" unless @permissions.include?("edit_buffer")
|
|
129
|
+
raise Canopus::Error, "buffer changed while plugin was running" unless buffer.version == version
|
|
130
|
+
buffer.edit(edits.map { |first, last, text| [first...last, text] }, kind: :plugin)
|
|
131
|
+
end
|
|
132
|
+
@workspace.message = result.fetch("messages").last.to_s unless result.fetch("messages").empty?
|
|
133
|
+
result["result"]
|
|
134
|
+
end
|
|
135
|
+
rescue IOError, Errno::EPIPE, EOFError => error
|
|
136
|
+
raise Canopus::Error, "plugin process ended: #{error.message}"
|
|
137
|
+
end
|
|
138
|
+
def close
|
|
139
|
+
@input&.close unless @input&.closed?
|
|
140
|
+
if @process && !@process.join(0.2)
|
|
141
|
+
Process.kill("KILL", @process.pid) rescue Errno::ESRCH
|
|
142
|
+
@process.join
|
|
143
|
+
end
|
|
144
|
+
@output&.close unless @output&.closed?
|
|
145
|
+
end
|
|
146
|
+
private
|
|
147
|
+
def response
|
|
148
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout
|
|
149
|
+
loop do
|
|
150
|
+
if (ending = @pending.index("\n"))
|
|
151
|
+
result = JSON.parse(@pending.slice!(0, ending + 1))
|
|
152
|
+
raise Canopus::Error, result["error"] if result["error"]
|
|
153
|
+
return result
|
|
154
|
+
end
|
|
155
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
156
|
+
if remaining <= 0 || !IO.select([@output], nil, nil, remaining)
|
|
157
|
+
close
|
|
158
|
+
raise Canopus::Error, "plugin exceeded #{@timeout} second response limit"
|
|
159
|
+
end
|
|
160
|
+
chunk = @output.read_nonblock(65_536)
|
|
161
|
+
@pending << chunk
|
|
162
|
+
raise Canopus::Error, "plugin response exceeds 1MB" if @pending.bytesize > 1 << 20
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canopus
|
|
4
|
+
module Plugins
|
|
5
|
+
class LocalRuntime
|
|
6
|
+
def initialize(workspace, source, path, permissions)
|
|
7
|
+
@workspace, @api = workspace, Canopus::Plugins::API.new(workspace, permissions)
|
|
8
|
+
instance_eval(source, path, 1)
|
|
9
|
+
end
|
|
10
|
+
def register_action(name, description: name, &block)
|
|
11
|
+
raise ArgumentError, "action callback required" unless block
|
|
12
|
+
@workspace.register_action(name, description: description) { block.call(@api) }
|
|
13
|
+
end
|
|
14
|
+
def register_panel(name, side: :right, &block)
|
|
15
|
+
raise ArgumentError, "panel callback required" unless block
|
|
16
|
+
@workspace.register_panel(name, side: side) { block.call(@api) }
|
|
17
|
+
end
|
|
18
|
+
def register_language(name, **options) = @workspace.register_language(name, **options)
|
|
19
|
+
def configure_lsp(language, command)
|
|
20
|
+
@workspace.settings.merge!("language_servers" => {language => command})
|
|
21
|
+
end
|
|
22
|
+
def close; end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canopus
|
|
4
|
+
module Plugins
|
|
5
|
+
class Registry
|
|
6
|
+
PERMISSIONS = %w[read_buffer edit_buffer read_project process network].freeze
|
|
7
|
+
|
|
8
|
+
def initialize(workspace)
|
|
9
|
+
@workspace, @loaded = workspace, []
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def load(path, trusted: false, permissions: [], isolated: true, timeout: 2)
|
|
13
|
+
raise PermissionDenied, "plugins execute Ruby code; explicitly mark this plugin trusted" unless trusted
|
|
14
|
+
permissions = permissions.map(&:to_s)
|
|
15
|
+
raise PermissionDenied, "unknown plugin permission" unless (permissions - PERMISSIONS).empty?
|
|
16
|
+
source = File.read(path, 256 * 1024 + 1, encoding: "UTF-8")
|
|
17
|
+
raise Error, "plugin source exceeds 256KB" if source.bytesize > 256 * 1024
|
|
18
|
+
plugin = if isolated
|
|
19
|
+
IsolatedRuntime.new(@workspace, source, path, permissions, timeout: timeout)
|
|
20
|
+
else
|
|
21
|
+
LocalRuntime.new(@workspace, source, path, permissions)
|
|
22
|
+
end
|
|
23
|
+
@loaded << plugin
|
|
24
|
+
plugin
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def close = @loaded.each(&:close)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canopus
|
|
4
|
+
module Plugins; end
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
require_relative "plugins/permission_denied"
|
|
8
|
+
require_relative "plugins/api"
|
|
9
|
+
require_relative "plugins/local_runtime"
|
|
10
|
+
require_relative "plugins/isolated_runtime"
|
|
11
|
+
require_relative "plugins/registry"
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Canopus::Project::IgnoreMatcher
|
|
4
|
+
Rule = Struct.new(:base, :expression, :negated, :directory, keyword_init: true)
|
|
5
|
+
|
|
6
|
+
def initialize(rules = [])
|
|
7
|
+
@rules = rules.freeze
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def add(source, base: "")
|
|
11
|
+
rules = source.lines.filter_map do |line|
|
|
12
|
+
line = line.delete_suffix("\n").delete_suffix("\r")
|
|
13
|
+
# Only unescaped trailing spaces are insignificant.
|
|
14
|
+
line = line.sub(/(?<!\\)(?:\\\\)*\K +\z/, "")
|
|
15
|
+
next if line.empty? || line.start_with?("#")
|
|
16
|
+
negated = line.start_with?("!")
|
|
17
|
+
line = line[1..] if negated
|
|
18
|
+
directory = line.end_with?("/")
|
|
19
|
+
line = line.delete_suffix("/") if directory
|
|
20
|
+
anchored = line.start_with?("/") || line.include?("/")
|
|
21
|
+
line = line.delete_prefix("/")
|
|
22
|
+
next if line.empty?
|
|
23
|
+
expression = Regexp.new((anchored ? "\\A" : "(?:\\A|/)") + glob(line) + "\\z")
|
|
24
|
+
Rule.new(base: base.delete_suffix("/"), expression: expression, negated: negated, directory: directory)
|
|
25
|
+
rescue RegexpError
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
self.class.new(@rules + rules)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Ancestors must be checked by the walker: excluded parents cannot be
|
|
32
|
+
# resurrected by a child's negation, exactly as Git's pruning requires.
|
|
33
|
+
def ignored?(path, directory: false)
|
|
34
|
+
ignored = false
|
|
35
|
+
@rules.each do |rule|
|
|
36
|
+
next if rule.directory && !directory
|
|
37
|
+
next unless rule.base.empty? || path.start_with?(rule.base + "/")
|
|
38
|
+
local = rule.base.empty? ? path : path[(rule.base.length + 1)..]
|
|
39
|
+
ignored = !rule.negated if rule.expression.match?(local)
|
|
40
|
+
end
|
|
41
|
+
ignored
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def glob(pattern)
|
|
47
|
+
result = +""
|
|
48
|
+
index = 0
|
|
49
|
+
while index < pattern.length
|
|
50
|
+
char = pattern[index]
|
|
51
|
+
case char
|
|
52
|
+
when "\\"
|
|
53
|
+
index += 1
|
|
54
|
+
result << Regexp.escape(pattern[index] || "\\")
|
|
55
|
+
when "*"
|
|
56
|
+
finish = index
|
|
57
|
+
finish += 1 while pattern[finish + 1] == "*"
|
|
58
|
+
if finish > index && (index.zero? || pattern[index - 1] == "/") && (finish == pattern.length - 1 || pattern[finish + 1] == "/")
|
|
59
|
+
if pattern[finish + 1] == "/"
|
|
60
|
+
result << "(?:[^/]+/)*"
|
|
61
|
+
finish += 1
|
|
62
|
+
else
|
|
63
|
+
result << ".*"
|
|
64
|
+
end
|
|
65
|
+
else
|
|
66
|
+
result << "[^/]*"
|
|
67
|
+
end
|
|
68
|
+
index = finish
|
|
69
|
+
when "?"
|
|
70
|
+
result << "[^/]"
|
|
71
|
+
when "["
|
|
72
|
+
cursor = index + 1
|
|
73
|
+
cursor += 1 if ["!", "^"].include?(pattern[cursor])
|
|
74
|
+
cursor += 1 if pattern[cursor] == "]"
|
|
75
|
+
finish = nil
|
|
76
|
+
while cursor < pattern.length
|
|
77
|
+
if pattern[cursor, 2] == "[:" && (ending = pattern.index(":]", cursor + 2))
|
|
78
|
+
cursor = ending + 2
|
|
79
|
+
next
|
|
80
|
+
end
|
|
81
|
+
if pattern[cursor] == "]"
|
|
82
|
+
finish = cursor
|
|
83
|
+
break
|
|
84
|
+
end
|
|
85
|
+
cursor += pattern[cursor] == "\\" ? 2 : 1
|
|
86
|
+
end
|
|
87
|
+
if finish
|
|
88
|
+
content = pattern[(index + 1)...finish].sub(/\A!/, "^")
|
|
89
|
+
content = content.gsub(/(?<!\\)(.)-(.)/) { Regexp.last_match(1).ord > Regexp.last_match(2).ord ? Regexp.last_match(1) : Regexp.last_match(0) }
|
|
90
|
+
result << "(?!/)[#{content}]"
|
|
91
|
+
index = finish
|
|
92
|
+
else
|
|
93
|
+
result << "\\["
|
|
94
|
+
end
|
|
95
|
+
else
|
|
96
|
+
result << Regexp.escape(char)
|
|
97
|
+
end
|
|
98
|
+
index += 1
|
|
99
|
+
end
|
|
100
|
+
result
|
|
101
|
+
rescue RegexpError
|
|
102
|
+
Regexp.escape(pattern)
|
|
103
|
+
end
|
|
104
|
+
end
|