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