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.
Files changed (146) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +148 -0
  5. data/docs/adr/001-runtime-gem-components.md +24 -0
  6. data/docs/adr/002-bounded-diff-engine.md +22 -0
  7. data/docs/adr/003-background-language-analysis.md +22 -0
  8. data/docs/adr/004-persistent-display-map.md +23 -0
  9. data/docs/adr/005-windows-runtime.md +27 -0
  10. data/docs/adr/README.md +36 -0
  11. data/docs/distribution.md +27 -0
  12. data/docs/lsp.md +38 -0
  13. data/docs/performance.md +40 -0
  14. data/docs/snippets.md +32 -0
  15. data/docs/vim.md +24 -0
  16. data/docs/workspace_edits.md +32 -0
  17. data/examples/native_smoke.rb +25 -0
  18. data/examples/plugins/word_count.rb +11 -0
  19. data/exe/canopus +19 -0
  20. data/lib/canopus/block_map.rb +19 -0
  21. data/lib/canopus/buffer.rb +293 -0
  22. data/lib/canopus/cli.rb +171 -0
  23. data/lib/canopus/controller.rb +507 -0
  24. data/lib/canopus/data_compat.rb +25 -0
  25. data/lib/canopus/display_map/line_builder.rb +54 -0
  26. data/lib/canopus/display_map/line_set.rb +8 -0
  27. data/lib/canopus/display_map/pending_line_set.rb +9 -0
  28. data/lib/canopus/display_map/summary.rb +23 -0
  29. data/lib/canopus/display_map/worker.rb +89 -0
  30. data/lib/canopus/display_map.rb +350 -0
  31. data/lib/canopus/display_point.rb +5 -0
  32. data/lib/canopus/editor/snippet_expandable.rb +281 -0
  33. data/lib/canopus/editor.rb +445 -0
  34. data/lib/canopus/error.rb +5 -0
  35. data/lib/canopus/fold_map.rb +87 -0
  36. data/lib/canopus/git/blame.rb +50 -0
  37. data/lib/canopus/git/commit.rb +7 -0
  38. data/lib/canopus/git/corrupt_object.rb +7 -0
  39. data/lib/canopus/git/diff.rb +131 -0
  40. data/lib/canopus/git/index.rb +94 -0
  41. data/lib/canopus/git/object_database.rb +45 -0
  42. data/lib/canopus/git/pack.rb +186 -0
  43. data/lib/canopus/git/repository.rb +313 -0
  44. data/lib/canopus/git/status.rb +74 -0
  45. data/lib/canopus/git/tree_entry.rb +7 -0
  46. data/lib/canopus/git.rb +15 -0
  47. data/lib/canopus/icon_theme.rb +43 -0
  48. data/lib/canopus/language/background_analysis/job.rb +12 -0
  49. data/lib/canopus/language/background_analysis/scheduler.rb +70 -0
  50. data/lib/canopus/language/background_analysis.rb +280 -0
  51. data/lib/canopus/language/definition.rb +7 -0
  52. data/lib/canopus/language/document.rb +143 -0
  53. data/lib/canopus/language/symbol.rb +7 -0
  54. data/lib/canopus/language/syntax_worker.rb +91 -0
  55. data/lib/canopus/language.rb +42 -0
  56. data/lib/canopus/lazy_rope.rb +218 -0
  57. data/lib/canopus/lsp/client.rb +299 -0
  58. data/lib/canopus/lsp/error.rb +7 -0
  59. data/lib/canopus/lsp/future/subscription.rb +9 -0
  60. data/lib/canopus/lsp/future.rb +87 -0
  61. data/lib/canopus/lsp/protocol.rb +83 -0
  62. data/lib/canopus/lsp/server_error.rb +13 -0
  63. data/lib/canopus/lsp/timeout.rb +7 -0
  64. data/lib/canopus/lsp/transport.rb +123 -0
  65. data/lib/canopus/lsp.rb +19 -0
  66. data/lib/canopus/markdown.rb +69 -0
  67. data/lib/canopus/match_data_compat.rb +17 -0
  68. data/lib/canopus/multi_buffer.rb +266 -0
  69. data/lib/canopus/pane.rb +61 -0
  70. data/lib/canopus/patch/composite.rb +12 -0
  71. data/lib/canopus/patch/reload.rb +18 -0
  72. data/lib/canopus/patch.rb +27 -0
  73. data/lib/canopus/performance_recorder.rb +207 -0
  74. data/lib/canopus/plugins/api.rb +50 -0
  75. data/lib/canopus/plugins/isolated_runtime.rb +167 -0
  76. data/lib/canopus/plugins/local_runtime.rb +25 -0
  77. data/lib/canopus/plugins/permission_denied.rb +7 -0
  78. data/lib/canopus/plugins/registry.rb +30 -0
  79. data/lib/canopus/plugins.rb +11 -0
  80. data/lib/canopus/project/ignore_matcher.rb +104 -0
  81. data/lib/canopus/project/search.rb +164 -0
  82. data/lib/canopus/project/search_worker/cancelled.rb +3 -0
  83. data/lib/canopus/project/search_worker/runner.rb +9 -0
  84. data/lib/canopus/project/search_worker.rb +108 -0
  85. data/lib/canopus/project/tree.rb +48 -0
  86. data/lib/canopus/project/watcher.rb +59 -0
  87. data/lib/canopus/project.rb +127 -0
  88. data/lib/canopus/regexp_compat.rb +30 -0
  89. data/lib/canopus/save_conflict.rb +5 -0
  90. data/lib/canopus/selection.rb +11 -0
  91. data/lib/canopus/settings.rb +118 -0
  92. data/lib/canopus/snippet/transform.rb +209 -0
  93. data/lib/canopus/snippet.rb +183 -0
  94. data/lib/canopus/tab_map.rb +30 -0
  95. data/lib/canopus/terminal/cell.rb +7 -0
  96. data/lib/canopus/terminal/grid.rb +321 -0
  97. data/lib/canopus/terminal/pty.rb +103 -0
  98. data/lib/canopus/terminal/scrollback.rb +38 -0
  99. data/lib/canopus/terminal/vt.rb +398 -0
  100. data/lib/canopus/terminal.rb +13 -0
  101. data/lib/canopus/theme.rb +43 -0
  102. data/lib/canopus/version.rb +5 -0
  103. data/lib/canopus/vim/commandable.rb +148 -0
  104. data/lib/canopus/vim/motionable.rb +321 -0
  105. data/lib/canopus/vim/operator_capable.rb +377 -0
  106. data/lib/canopus/vim/text_object_selectable.rb +141 -0
  107. data/lib/canopus/vim.rb +426 -0
  108. data/lib/canopus/workspace/edit/executable.rb +139 -0
  109. data/lib/canopus/workspace/edit/node.rb +8 -0
  110. data/lib/canopus/workspace/edit/plan.rb +170 -0
  111. data/lib/canopus/workspace/edit/resource_preparable.rb +98 -0
  112. data/lib/canopus/workspace/edit.rb +6 -0
  113. data/lib/canopus/workspace/file_change_aware.rb +40 -0
  114. data/lib/canopus/workspace/file_previewable.rb +31 -0
  115. data/lib/canopus/workspace/git_aware.rb +140 -0
  116. data/lib/canopus/workspace/language_aware.rb +413 -0
  117. data/lib/canopus/workspace/language_server_configurable.rb +181 -0
  118. data/lib/canopus/workspace/project_searchable.rb +208 -0
  119. data/lib/canopus/workspace/project_tree_editable.rb +82 -0
  120. data/lib/canopus/workspace/session_persistable.rb +153 -0
  121. data/lib/canopus/workspace/settings_aware.rb +91 -0
  122. data/lib/canopus/workspace/view/terminal_presentable.rb +185 -0
  123. data/lib/canopus/workspace/view.rb +643 -0
  124. data/lib/canopus/workspace.rb +525 -0
  125. data/lib/canopus/wrap_map.rb +108 -0
  126. data/lib/canopus.rb +24 -0
  127. data/sig/canopus.rbs +375 -0
  128. data/sig/controller.rbs +55 -0
  129. data/sig/display_stages.rbs +49 -0
  130. data/sig/git.rbs +140 -0
  131. data/sig/lsp.rbs +99 -0
  132. data/sig/markdown.rbs +11 -0
  133. data/sig/performance_recorder.rbs +23 -0
  134. data/sig/search_services.rbs +8 -0
  135. data/sig/services.rbs +81 -0
  136. data/sig/snippet.rbs +42 -0
  137. data/sig/terminal.rbs +125 -0
  138. data/sig/vim.rbs +27 -0
  139. data/sig/workspace_edits.rbs +11 -0
  140. data/sig/workspace_services.rbs +117 -0
  141. data/tools/certify_snippet_regex.rb +35 -0
  142. data/tools/check_dependencies.rb +80 -0
  143. data/tools/native_check.rb +70 -0
  144. data/tools/package.rb +113 -0
  145. data/tools/package_test.rb +32 -0
  146. 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canopus
4
+ module Plugins
5
+ class PermissionDenied < Error; end
6
+ end
7
+ 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