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,293 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
|
|
6
|
+
module Canopus
|
|
7
|
+
class Buffer
|
|
8
|
+
Transaction = Struct.new(:before, :after, :before_selections, :after_selections, :patch, :kind, :time)
|
|
9
|
+
Anchor = Data.define(:id, :bias)
|
|
10
|
+
attr_reader :rope, :path, :encoding, :line_ending, :version, :history, :read_only, :disk_digest, :bom, :notification_errors
|
|
11
|
+
attr_accessor :selections
|
|
12
|
+
|
|
13
|
+
def self.open(path, large_file_threshold: 100 << 20)
|
|
14
|
+
if File.size(path) >= large_file_threshold
|
|
15
|
+
require_relative "lazy_rope"
|
|
16
|
+
rope = LazyRope.new(path)
|
|
17
|
+
return new("", path: path, read_only: true, rope: rope)
|
|
18
|
+
end
|
|
19
|
+
raw = File.binread(path)
|
|
20
|
+
text, encoding, bom = decode_bytes(raw)
|
|
21
|
+
new(text, path: path, encoding: encoding, bom: bom, disk_digest: Digest::SHA256.hexdigest(raw))
|
|
22
|
+
rescue EncodingError => error
|
|
23
|
+
raise Error, "cannot decode #{path}: #{error.message}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Decode worktree files and historical Git blobs by the same rules.
|
|
27
|
+
# @return [Array(String, Encoding, String)] UTF-8 text, source encoding, BOM
|
|
28
|
+
def self.decode_bytes(raw)
|
|
29
|
+
raw = raw.b unless raw.encoding == Encoding::BINARY
|
|
30
|
+
bom, encoding = if raw.start_with?("\xEF\xBB\xBF".b)
|
|
31
|
+
["\xEF\xBB\xBF".b, Encoding::UTF_8]
|
|
32
|
+
elsif raw.start_with?("\xFF\xFE".b)
|
|
33
|
+
["\xFF\xFE".b, Encoding::UTF_16LE]
|
|
34
|
+
elsif raw.start_with?("\xFE\xFF".b)
|
|
35
|
+
["\xFE\xFF".b, Encoding::UTF_16BE]
|
|
36
|
+
elsif raw.dup.force_encoding(Encoding::UTF_8).valid_encoding?
|
|
37
|
+
["".b, Encoding::UTF_8]
|
|
38
|
+
else
|
|
39
|
+
["".b, Encoding::Windows_31J]
|
|
40
|
+
end
|
|
41
|
+
text = raw.byteslice(bom.bytesize..).force_encoding(encoding).encode(Encoding::UTF_8)
|
|
42
|
+
raise Error, "binary file contains NUL bytes" if text.include?("\0")
|
|
43
|
+
[text, encoding, bom]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def initialize(text = "", path: nil, encoding: Encoding::UTF_8, bom: "".b, disk_digest: nil, read_only: false, draft: false, rope: nil)
|
|
47
|
+
@rope = rope || Denebola::Rope.new(text)
|
|
48
|
+
@path = path && File.expand_path(path)
|
|
49
|
+
@encoding, @bom, @disk_digest = encoding, bom, disk_digest
|
|
50
|
+
@saved_rope, @version, @history, @redo = @rope, 0, [], []
|
|
51
|
+
@saved_rope = nil if draft
|
|
52
|
+
@listeners, @anchors, @next_anchor, @selections = [], {}, 0, []
|
|
53
|
+
@notification_errors = [].freeze
|
|
54
|
+
@line_ending = text[/\r\n|\n|\r|\u2028|\u2029/] || "\n"
|
|
55
|
+
@line_ending = if rope.respond_to?(:line_ending)
|
|
56
|
+
rope.line_ending
|
|
57
|
+
elsif rope && text.empty?
|
|
58
|
+
snapshot_line_ending(rope)
|
|
59
|
+
else
|
|
60
|
+
@line_ending
|
|
61
|
+
end
|
|
62
|
+
@read_only = read_only
|
|
63
|
+
end
|
|
64
|
+
def text = rope.to_s
|
|
65
|
+
def relocate(path) = @path = File.expand_path(path)
|
|
66
|
+
def close = (@rope.close if @rope.respond_to?(:close))
|
|
67
|
+
def line(row) = rope.line(row)
|
|
68
|
+
def line_count = rope.line_count
|
|
69
|
+
def dirty? = !@rope.equal?(@saved_rope)
|
|
70
|
+
def reload(force: false)
|
|
71
|
+
raise SaveConflict, "buffer has unsaved changes" if dirty? && !force
|
|
72
|
+
raise Error, "buffer has no file path" unless @path
|
|
73
|
+
if @rope.respond_to?(:lazy?) && @rope.lazy?
|
|
74
|
+
fresh = Buffer.open(@path, large_file_threshold: 0)
|
|
75
|
+
old = @rope
|
|
76
|
+
apply_snapshot(fresh.rope, Patch::Reload.new(old, fresh.rope))
|
|
77
|
+
old.close
|
|
78
|
+
else
|
|
79
|
+
fresh = Buffer.open(@path)
|
|
80
|
+
raise Error, "file grew beyond the editable limit; reopen it read-only" if fresh.read_only
|
|
81
|
+
before, after = text, fresh.text
|
|
82
|
+
if before != after
|
|
83
|
+
first, shared = 0, [before.bytesize, after.bytesize].min
|
|
84
|
+
first += 4096 while first + 4096 <= shared && before.byteslice(first, 4096) == after.byteslice(first, 4096)
|
|
85
|
+
first += 1 while first < shared && before.getbyte(first) == after.getbyte(first)
|
|
86
|
+
first -= 1 while first.positive? && before.getbyte(first)&.&(0xc0) == 0x80
|
|
87
|
+
last = 0
|
|
88
|
+
last += 4096 while last + 4096 <= shared - first && before.byteslice(before.bytesize - last - 4096, 4096) == after.byteslice(after.bytesize - last - 4096, 4096)
|
|
89
|
+
last += 1 while last < shared - first && before.getbyte(before.bytesize - last - 1) == after.getbyte(after.bytesize - last - 1)
|
|
90
|
+
last -= 1 while last.positive? && before.getbyte(before.bytesize - last)&.&(0xc0) == 0x80
|
|
91
|
+
edit([[first...(before.bytesize - last), after.byteslice(first, after.bytesize - first - last)]], kind: :reload)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
@saved_rope = @rope
|
|
95
|
+
@disk_digest, @encoding, @bom, @line_ending = fresh.disk_digest, fresh.encoding, fresh.bom, fresh.line_ending
|
|
96
|
+
self
|
|
97
|
+
ensure
|
|
98
|
+
fresh&.close unless fresh&.rope.equal?(@rope)
|
|
99
|
+
end
|
|
100
|
+
# Notifications observe an already committed edit (including undo/redo).
|
|
101
|
+
# A listener's StandardError is reported, not raised as an edit failure;
|
|
102
|
+
# remaining listeners still run. Keep only the latest 32 bounded messages.
|
|
103
|
+
def on_edit(&listener)
|
|
104
|
+
@listeners << listener
|
|
105
|
+
Zaniah::Subscription.new { @listeners.delete(listener) }
|
|
106
|
+
end
|
|
107
|
+
def anchor(offset, bias: :right)
|
|
108
|
+
raise ArgumentError, "bias must be :left or :right" unless bias == :left || bias == :right
|
|
109
|
+
@rope.point_at(offset)
|
|
110
|
+
anchor = Anchor.new(@next_anchor, bias)
|
|
111
|
+
@next_anchor += 1
|
|
112
|
+
@anchors[anchor] = offset
|
|
113
|
+
anchor
|
|
114
|
+
end
|
|
115
|
+
def resolve(anchor) = @anchors.fetch(anchor)
|
|
116
|
+
def release_anchor(anchor) = @anchors.delete(anchor)
|
|
117
|
+
|
|
118
|
+
# Copy a registered position only from the exact same immutable snapshot.
|
|
119
|
+
# This avoids repeating a rope lookup for privately prepared excerpts.
|
|
120
|
+
def copy_anchor(source, anchor)
|
|
121
|
+
raise ArgumentError, "anchor source must be a regular buffer" unless source.is_a?(Buffer) && !source.is_a?(MultiBuffer)
|
|
122
|
+
raise ArgumentError, "anchor source snapshot differs" unless source.rope.equal?(@rope)
|
|
123
|
+
offset = source.resolve(anchor)
|
|
124
|
+
copied = Anchor.new(@next_anchor, anchor.bias)
|
|
125
|
+
@next_anchor += 1
|
|
126
|
+
@anchors[copied] = offset
|
|
127
|
+
copied
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def begin_undo_group
|
|
131
|
+
@undo_group_depth ||= 0
|
|
132
|
+
@undo_group_start = @history.length if @undo_group_depth.zero?
|
|
133
|
+
@undo_group_depth += 1
|
|
134
|
+
self
|
|
135
|
+
end
|
|
136
|
+
def end_undo_group
|
|
137
|
+
raise Error, "no undo group is open" unless @undo_group_depth&.positive?
|
|
138
|
+
@undo_group_depth -= 1
|
|
139
|
+
@undo_group_start = nil if @undo_group_depth.zero?
|
|
140
|
+
self
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def edit(changes, kind: :edit, selections: nil, before_selections: @selections, time: Process.clock_gettime(Process::CLOCK_MONOTONIC), group: false)
|
|
144
|
+
raise Error, "buffer is read-only" if @read_only
|
|
145
|
+
return if changes.empty?
|
|
146
|
+
before, after = @rope, @rope.apply_edits(changes)
|
|
147
|
+
patch = Patch.new(before, after, changes)
|
|
148
|
+
# Direct edits (formatting, LSP, excerpt sources) have no Editor to supply
|
|
149
|
+
# the post-edit cursor positions. Preserve explicit selection overrides.
|
|
150
|
+
selections ||= before_selections.map do |selection|
|
|
151
|
+
selection.with(anchor: patch.map_offset(selection.anchor), head: patch.map_offset(selection.head))
|
|
152
|
+
end
|
|
153
|
+
transaction = Transaction.new(before, after, before_selections.dup.freeze, selections.dup.freeze, patch, kind, time)
|
|
154
|
+
previous = @history.last
|
|
155
|
+
explicit_group = @undo_group_depth&.positive?
|
|
156
|
+
merge = explicit_group ? @history.length > @undo_group_start : group && previous && previous.kind == kind && time - previous.time < 0.3 && previous.after_selections == before_selections
|
|
157
|
+
if merge && previous && previous.after.equal?(before)
|
|
158
|
+
previous.after, previous.after_selections, previous.time = after, transaction.after_selections, time
|
|
159
|
+
previous.patch = previous.patch.compose(patch)
|
|
160
|
+
else
|
|
161
|
+
@history << transaction
|
|
162
|
+
end
|
|
163
|
+
@redo.clear
|
|
164
|
+
@selections = selections
|
|
165
|
+
apply_snapshot(after, patch)
|
|
166
|
+
patch
|
|
167
|
+
end
|
|
168
|
+
def undo
|
|
169
|
+
raise Error, "end the undo group before undo" if @undo_group_depth&.positive?
|
|
170
|
+
transaction = @history.pop
|
|
171
|
+
return false unless transaction
|
|
172
|
+
@redo << transaction
|
|
173
|
+
@selections = transaction.before_selections.dup
|
|
174
|
+
apply_snapshot(transaction.before, transaction.patch.inverse)
|
|
175
|
+
true
|
|
176
|
+
end
|
|
177
|
+
def redo
|
|
178
|
+
raise Error, "end the undo group before redo" if @undo_group_depth&.positive?
|
|
179
|
+
transaction = @redo.pop
|
|
180
|
+
return false unless transaction
|
|
181
|
+
@history << transaction
|
|
182
|
+
@selections = transaction.after_selections.dup
|
|
183
|
+
apply_snapshot(transaction.after, transaction.patch)
|
|
184
|
+
true
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Encode first, write and fsync a sibling temporary file, atomically rename.
|
|
188
|
+
def save(path = @path, force: false)
|
|
189
|
+
raise Error, "no file path" unless path
|
|
190
|
+
raise Error, "buffer is read-only" if @read_only
|
|
191
|
+
destination = File.expand_path(path)
|
|
192
|
+
destination = File.realpath(destination) if File.symlink?(destination)
|
|
193
|
+
original = File.file?(destination) ? File.binread(destination) : nil
|
|
194
|
+
same_file = @path && (File.expand_path(path) == @path || (File.exist?(@path) && File.exist?(destination) && File.identical?(@path, destination)))
|
|
195
|
+
if !force && same_file && @disk_digest && (!original || Digest::SHA256.hexdigest(original) != @disk_digest)
|
|
196
|
+
raise SaveConflict, "file changed on disk: #{path}"
|
|
197
|
+
end
|
|
198
|
+
raise SaveConflict, "destination exists: #{path}" if !force && original && (!same_file || !@disk_digest)
|
|
199
|
+
encoded = @bom + text.encode(@encoding).b
|
|
200
|
+
Tempfile.create([".canopus-", ".tmp"], File.dirname(destination), binmode: true) do |file|
|
|
201
|
+
file.chmod(File.stat(destination).mode & 0o777) if original
|
|
202
|
+
file.write(encoded)
|
|
203
|
+
file.flush
|
|
204
|
+
file.fsync
|
|
205
|
+
file.close
|
|
206
|
+
current = File.file?(destination) ? File.binread(destination) : nil
|
|
207
|
+
raise SaveConflict, "file changed during save: #{path}" if !force && current != original
|
|
208
|
+
File.rename(file.path, destination)
|
|
209
|
+
end
|
|
210
|
+
@path = File.expand_path(path)
|
|
211
|
+
@saved_rope, @disk_digest = @rope, Digest::SHA256.hexdigest(encoded)
|
|
212
|
+
self
|
|
213
|
+
rescue EncodingError => error
|
|
214
|
+
raise Error, "cannot save using #{@encoding}: #{error.message}"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def trim_trailing_whitespace
|
|
218
|
+
changes = []
|
|
219
|
+
rope.line_count.times do |row|
|
|
220
|
+
value = rope.line(row)
|
|
221
|
+
whitespace = value[/[ \t]+\z/]
|
|
222
|
+
next unless whitespace
|
|
223
|
+
ending = rope.line_start(row) + value.bytesize
|
|
224
|
+
changes << [(ending - whitespace.bytesize)...ending, ""]
|
|
225
|
+
end
|
|
226
|
+
edit(changes, kind: :format)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
private
|
|
230
|
+
def snapshot_line_ending(rope)
|
|
231
|
+
return "\n" if rope.line_count < 2
|
|
232
|
+
ending = rope.line_start(1)
|
|
233
|
+
first = [ending - 3, 0].max
|
|
234
|
+
begin
|
|
235
|
+
tail = rope.byteslice(first, ending - first).to_s
|
|
236
|
+
rescue RangeError
|
|
237
|
+
first += 1 # The three-byte suffix can start inside the preceding glyph.
|
|
238
|
+
retry
|
|
239
|
+
end
|
|
240
|
+
tail[/(?:\r\n|[\r\n\u2028\u2029])\z/] || "\n"
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# MultiBuffer prepares all source edits first. If a source implementation
|
|
244
|
+
# raises during application, restore its in-memory edit state without
|
|
245
|
+
# consuming an older undo entry or leaving the aborted edit in redo.
|
|
246
|
+
def edit_checkpoint
|
|
247
|
+
history = @history.dup
|
|
248
|
+
history[-1] = history.last.dup unless history.empty? # Grouping only mutates the last entry.
|
|
249
|
+
{rope: @rope, history: history, redo: @redo.dup,
|
|
250
|
+
selections: @selections, anchors: @anchors.dup}
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def restore_edit(checkpoint, inverse)
|
|
254
|
+
@rope = checkpoint.fetch(:rope)
|
|
255
|
+
@history.replace(checkpoint.fetch(:history))
|
|
256
|
+
@redo.replace(checkpoint.fetch(:redo))
|
|
257
|
+
@selections = checkpoint.fetch(:selections)
|
|
258
|
+
anchors = checkpoint.fetch(:anchors)
|
|
259
|
+
@anchors.each do |anchor, offset|
|
|
260
|
+
# An observer may create/release anchors (e.g. merge its excerpts).
|
|
261
|
+
# Preserve their lifetime, while restoring surviving pre-edit anchors
|
|
262
|
+
# exactly, including positions inside a deleted span.
|
|
263
|
+
@anchors[anchor] = anchors.fetch(anchor) { inverse.map_offset(offset, bias: anchor.bias) }
|
|
264
|
+
end
|
|
265
|
+
# Observers may have seen the failed source's mutation. Announce its
|
|
266
|
+
# compensation with a newer version; arbitrary callback side effects
|
|
267
|
+
# cannot be undone by the buffer.
|
|
268
|
+
@version += 1
|
|
269
|
+
notify_edit(inverse)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def apply_snapshot(snapshot, patch)
|
|
273
|
+
@rope = snapshot
|
|
274
|
+
@anchors.each { |anchor, offset| @anchors[anchor] = patch.map_offset(offset, bias: anchor.bias) }
|
|
275
|
+
@version += 1
|
|
276
|
+
notify_edit(patch)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def notify_edit(patch)
|
|
280
|
+
@listeners.dup.each do |listener|
|
|
281
|
+
listener.call(patch)
|
|
282
|
+
rescue StandardError => error
|
|
283
|
+
message = "#{error.class}: #{error.message}".encode(Encoding::UTF_8, invalid: :replace, undef: :replace).byteslice(0, 2048).scrub("")
|
|
284
|
+
@notification_errors = [*@notification_errors.last(31), Error.new(message)].freeze
|
|
285
|
+
begin
|
|
286
|
+
warn "Canopus buffer notification failed: #{message}"
|
|
287
|
+
rescue StandardError
|
|
288
|
+
# A broken stderr cannot turn an already committed edit into failure.
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
data/lib/canopus/cli.rb
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Canopus
|
|
7
|
+
module CLI
|
|
8
|
+
def self.main(argv = ARGV, output: $stdout, error: $stderr)
|
|
9
|
+
options = {width: 1100, height: 760, root: Dir.pwd, platform: nil, gpu: nil, plugins: [], grants: []}
|
|
10
|
+
parser = OptionParser.new do |flags|
|
|
11
|
+
flags.banner = "Usage: canopus [options] [files...]"
|
|
12
|
+
flags.on("--project DIRECTORY", "Project root") { |value| options[:root] = value }
|
|
13
|
+
flags.on("--headless PNG", "Render to a PNG without opening a window") { |value| options[:png] = value; options[:platform] = :headless }
|
|
14
|
+
flags.on("--tui", "Run in the current terminal") { options[:platform] = :tui }
|
|
15
|
+
flags.on("--backend BACKEND", %w[metal opengl], "GPU backend") { |value| options[:gpu] = value.to_sym }
|
|
16
|
+
flags.on("--size WIDTHxHEIGHT", /\A\d+x\d+\z/, "Window dimensions") { |value| options[:width], options[:height] = value.split("x").map(&:to_i) }
|
|
17
|
+
flags.on("--settings JSONC", "Settings file") { |value| options[:settings] = value }
|
|
18
|
+
flags.on("--session JSON", "Restore and save a workspace session") { |value| options[:session] = value }
|
|
19
|
+
flags.on("--replay JSON", "Replay text, key, and action input records") { |value| options[:replay] = value }
|
|
20
|
+
flags.on("--glyph-cache DIRECTORY", "Reuse a checksummed glyph atlas in this directory") { |value| options[:glyph_cache] = value }
|
|
21
|
+
flags.on("--profile JSON_PATH", "Save frame timings and sampled Ruby stacks on exit") { |value| options[:profile] = value }
|
|
22
|
+
flags.on("--trace-allocations", "Trace live allocation sites (slow; requires --profile)") { options[:trace_allocations] = true }
|
|
23
|
+
flags.on("--crash-report JSON_PATH", "Save exceptions locally; review before sharing") { |value| options[:crash_report] = value }
|
|
24
|
+
flags.on("--vim", "Enable Vim keybindings") { options[:vim] = true }
|
|
25
|
+
flags.on("--plugin RUBY", "Load a Ruby plugin (requires --trust-plugins)") { |value| options[:plugins] << value }
|
|
26
|
+
flags.on("--trust-plugins", "Allow the selected plugins to execute trusted Ruby code") { options[:trust_plugins] = true }
|
|
27
|
+
flags.on("--grant PERMISSION", Plugins::Registry::PERMISSIONS, "Grant a plugin API permission (repeatable)") { |value| options[:grants] << value }
|
|
28
|
+
flags.on("--plugins-in-process", "Run trusted plugins in the editor process") { options[:plugins_in_process] = true }
|
|
29
|
+
flags.on("--version") { output.puts(VERSION); return 0 }
|
|
30
|
+
flags.on("--help") { output.puts(flags); return 0 }
|
|
31
|
+
end
|
|
32
|
+
files = parser.parse(argv.dup)
|
|
33
|
+
validate_cli_options(options, files)
|
|
34
|
+
run_cli(options, files, output: output, error: error)
|
|
35
|
+
rescue OptionParser::ParseError, StandardError => exception
|
|
36
|
+
error.puts("canopus: #{exception.message}")
|
|
37
|
+
1
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.validate_cli_options(options, files)
|
|
41
|
+
unless options.values_at(:width, :height).all? { |value| value.between?(100, 16_384) }
|
|
42
|
+
raise ArgumentError, "window dimensions must be between 100 and 16384"
|
|
43
|
+
end
|
|
44
|
+
raise ArgumentError, "--trace-allocations requires --profile" if options[:trace_allocations] && !options[:profile]
|
|
45
|
+
if options[:glyph_cache] && File.exist?(options[:glyph_cache]) && !File.directory?(options[:glyph_cache])
|
|
46
|
+
raise ArgumentError, "--glyph-cache must name a directory"
|
|
47
|
+
end
|
|
48
|
+
return unless options[:profile] || options[:crash_report]
|
|
49
|
+
require_relative "performance_recorder"
|
|
50
|
+
protected = files.map { |path| File.expand_path(path, options[:root]) }
|
|
51
|
+
protected.concat(options.values_at(:png, :settings, :session, :replay)).concat(options[:plugins])
|
|
52
|
+
protected << Settings.user_path << File.join(File.expand_path(options[:root]), ".canopus", "settings.jsonc")
|
|
53
|
+
PerformanceRecorder.validate_paths(options.values_at(:profile, :crash_report), protected: protected)
|
|
54
|
+
end
|
|
55
|
+
private_class_method :validate_cli_options
|
|
56
|
+
|
|
57
|
+
def self.run_cli(options, files, output:, error:)
|
|
58
|
+
settings = Settings.new(Settings.user_path, File.join(File.expand_path(options[:root]), ".canopus", "settings.jsonc"),
|
|
59
|
+
options[:settings], options[:vim] ? {"vim_mode" => true} : {})
|
|
60
|
+
workspace = Workspace.new(root: options[:root], settings: settings)
|
|
61
|
+
workspace.restore_session(options[:session]) if options[:session] && File.file?(options[:session])
|
|
62
|
+
files.each { |path| workspace.open(path) }
|
|
63
|
+
platform = options[:platform] || (RUBY_PLATFORM.include?("darwin") ? :mac : RUBY_PLATFORM.match?(/mswin|mingw/) ? :windows : :linux)
|
|
64
|
+
window_options = {width: options[:width], height: options[:height], title: "Canopus"}
|
|
65
|
+
window_options[:gpu] = options[:gpu] if options[:gpu] && platform != :headless && platform != :tui
|
|
66
|
+
if platform == :tui
|
|
67
|
+
require "io/console"
|
|
68
|
+
rows, columns = output.winsize if output.respond_to?(:winsize) && output.tty?
|
|
69
|
+
window_options.merge!(width: columns * 8, height: rows * 20) if rows && rows.positive?
|
|
70
|
+
window_options[:output] = output
|
|
71
|
+
end
|
|
72
|
+
window = Zaniah::Platform.open_window(backend: platform, **window_options)
|
|
73
|
+
if options[:profile]
|
|
74
|
+
performance = PerformanceRecorder.new(window, trace_allocations: options[:trace_allocations]).start
|
|
75
|
+
workspace.performance = performance
|
|
76
|
+
end
|
|
77
|
+
controller = Controller.new(workspace, window)
|
|
78
|
+
workspace.configure_text_system(cache_dir: options[:glyph_cache]) unless platform == :tui
|
|
79
|
+
workspace.apply_settings
|
|
80
|
+
warmup = Thread.new { window.text_system.prewarm(size: settings["font_size"]) } unless platform == :tui
|
|
81
|
+
warmup.report_on_exception = false if warmup
|
|
82
|
+
workspace.start_watching unless platform == :headless
|
|
83
|
+
warmup&.value
|
|
84
|
+
GC.compact if GC.respond_to?(:compact)
|
|
85
|
+
options[:plugins].each { |path| workspace.plugins.load(path, trusted: options[:trust_plugins], permissions: options[:grants], isolated: !options[:plugins_in_process]) }
|
|
86
|
+
if options[:replay]
|
|
87
|
+
records = JSON.parse(File.read(options[:replay]))
|
|
88
|
+
raise ArgumentError, "replay must be an array" unless records.is_a?(Array)
|
|
89
|
+
records.each do |record|
|
|
90
|
+
case record.fetch("type")
|
|
91
|
+
when "text" then controller.input(Zaniah::Input::TextInput.new(record.fetch("text")))
|
|
92
|
+
when "key" then controller.input(Zaniah::Input::KeyDown.new(record.fetch("key"), false))
|
|
93
|
+
when "action" then workspace.call(record.fetch("action"))
|
|
94
|
+
else raise ArgumentError, "unknown replay input"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
if options[:png]
|
|
99
|
+
settle_export(controller, window)
|
|
100
|
+
window.write_png(options[:png])
|
|
101
|
+
output.puts(options[:png])
|
|
102
|
+
else
|
|
103
|
+
window.run
|
|
104
|
+
end
|
|
105
|
+
workspace.save_session(options[:session]) if options[:session]
|
|
106
|
+
0
|
|
107
|
+
rescue Exception => exception
|
|
108
|
+
# Fatal Ruby exceptions are re-raised after cleanup/reporting, not swallowed.
|
|
109
|
+
failure = exception
|
|
110
|
+
ensure
|
|
111
|
+
begin
|
|
112
|
+
warmup&.join
|
|
113
|
+
rescue Exception => exception
|
|
114
|
+
failure ||= exception
|
|
115
|
+
end
|
|
116
|
+
failure = finish_cli(options, workspace, window, performance, failure, error: error)
|
|
117
|
+
raise failure if failure
|
|
118
|
+
end
|
|
119
|
+
private_class_method :run_cli
|
|
120
|
+
|
|
121
|
+
# Exports need settled colors/wrapping; interactive windows intentionally show
|
|
122
|
+
# provisional content immediately. Do not repaint every background batch.
|
|
123
|
+
def self.settle_export(controller, window, timeout: 30)
|
|
124
|
+
controller.tick
|
|
125
|
+
workspace = controller.workspace
|
|
126
|
+
pending = lambda do
|
|
127
|
+
waiting = false
|
|
128
|
+
workspace.panes.filter_map(&:active).each do |editor|
|
|
129
|
+
error = editor.display_map.layout_error || editor.language_document.analysis_error
|
|
130
|
+
raise Error, "PNG export failed: #{error.message}" if error
|
|
131
|
+
waiting ||= editor.display_map.pending? || editor.language_document.pending?
|
|
132
|
+
end
|
|
133
|
+
waiting
|
|
134
|
+
end
|
|
135
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
136
|
+
while pending.call
|
|
137
|
+
while pending.call
|
|
138
|
+
raise Error, "PNG export timed out waiting for background layout or syntax" if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
139
|
+
workspace.drain
|
|
140
|
+
controller.poll_display_maps
|
|
141
|
+
controller.poll_language_documents
|
|
142
|
+
sleep 0.002 if pending.call
|
|
143
|
+
end
|
|
144
|
+
window.request_frame
|
|
145
|
+
controller.tick
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
private_class_method :settle_export
|
|
149
|
+
|
|
150
|
+
def self.finish_cli(options, workspace, window, performance, failure, error:)
|
|
151
|
+
operations = [-> { performance&.stop }, -> { workspace&.close },
|
|
152
|
+
-> { window&.on_close { true }; window&.close },
|
|
153
|
+
-> { performance&.write(options[:profile]) }]
|
|
154
|
+
operations.each do |operation|
|
|
155
|
+
operation.call
|
|
156
|
+
rescue StandardError => exception
|
|
157
|
+
error.puts("canopus: cleanup/report failed: #{exception.message}") if failure
|
|
158
|
+
failure ||= exception
|
|
159
|
+
end
|
|
160
|
+
if failure && options[:crash_report]
|
|
161
|
+
begin
|
|
162
|
+
PerformanceRecorder.write_crash(options[:crash_report], failure, window: window)
|
|
163
|
+
rescue StandardError => exception
|
|
164
|
+
error.puts("canopus: crash report failed: #{exception.message}")
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
failure
|
|
168
|
+
end
|
|
169
|
+
private_class_method :finish_cli
|
|
170
|
+
end
|
|
171
|
+
end
|