canopus 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +191 -19
  4. data/assets/keymaps/emacs.jsonc +5 -0
  5. data/assets/keymaps/jetbrains.jsonc +5 -0
  6. data/assets/keymaps/sublime.jsonc +5 -0
  7. data/assets/keymaps/vscode.jsonc +5 -0
  8. data/docs/debugging.md +143 -0
  9. data/docs/distribution.md +40 -9
  10. data/docs/lsp.md +131 -0
  11. data/docs/performance.md +18 -0
  12. data/docs/providers.md +36 -0
  13. data/docs/release-log.md +12 -0
  14. data/docs/tasks.md +64 -0
  15. data/docs/testing.md +49 -0
  16. data/docs/vim.md +3 -0
  17. data/exe/canopus +4 -1
  18. data/lib/canopus/buffer.rb +291 -50
  19. data/lib/canopus/cli.rb +5 -2
  20. data/lib/canopus/command.rb +13 -4
  21. data/lib/canopus/controller.rb +183 -40
  22. data/lib/canopus/debug/breakpoints.rb +550 -0
  23. data/lib/canopus/debug/configuration.rb +291 -0
  24. data/lib/canopus/debug/console.rb +243 -0
  25. data/lib/canopus/debug/panel.rb +487 -0
  26. data/lib/canopus/debug/session.rb +354 -0
  27. data/lib/canopus/decoration.rb +4 -2
  28. data/lib/canopus/denebola_compat.rb +27 -0
  29. data/lib/canopus/diagnostics.rb +173 -0
  30. data/lib/canopus/display_map/overlay_map.rb +6 -2
  31. data/lib/canopus/editor/snippet_expandable.rb +3 -2
  32. data/lib/canopus/editor.rb +122 -31
  33. data/lib/canopus/git/state.rb +100 -0
  34. data/lib/canopus/language/background_analysis.rb +93 -14
  35. data/lib/canopus/language/document.rb +70 -34
  36. data/lib/canopus/language/symbol.rb +1 -0
  37. data/lib/canopus/language/syntax_worker.rb +61 -9
  38. data/lib/canopus/language.rb +3 -0
  39. data/lib/canopus/minimap.rb +201 -0
  40. data/lib/canopus/patch.rb +3 -1
  41. data/lib/canopus/plugins/api.rb +4 -2
  42. data/lib/canopus/plugins/isolated_runtime.rb +64 -7
  43. data/lib/canopus/plugins/registry.rb +114 -3
  44. data/lib/canopus/plugins.rb +8 -7
  45. data/lib/canopus/project.rb +15 -6
  46. data/lib/canopus/provider.rb +192 -0
  47. data/lib/canopus/regexp_compat.rb +36 -4
  48. data/lib/canopus/selection.rb +12 -0
  49. data/lib/canopus/settings/schema.rb +201 -0
  50. data/lib/canopus/settings.rb +328 -49
  51. data/lib/canopus/task/configuration.rb +356 -0
  52. data/lib/canopus/task/problem_matcher.rb +264 -0
  53. data/lib/canopus/task/runner.rb +314 -0
  54. data/lib/canopus/test_runner/bounded_ignore.rb +144 -0
  55. data/lib/canopus/test_runner/discovery.rb +97 -0
  56. data/lib/canopus/test_runner/execution.rb +194 -0
  57. data/lib/canopus/test_runner/minitest.rb +116 -0
  58. data/lib/canopus/test_runner/rspec.rb +103 -0
  59. data/lib/canopus/test_runner/safe_walker.rb +37 -0
  60. data/lib/canopus/test_runner.rb +54 -0
  61. data/lib/canopus/theme/import.rb +131 -0
  62. data/lib/canopus/theme.rb +28 -4
  63. data/lib/canopus/version.rb +1 -1
  64. data/lib/canopus/vim/motionable.rb +1 -1
  65. data/lib/canopus/vim.rb +10 -16
  66. data/lib/canopus/workspace/auto_savable.rb +83 -0
  67. data/lib/canopus/workspace/debug_aware.rb +600 -0
  68. data/lib/canopus/workspace/edit/plan.rb +2 -2
  69. data/lib/canopus/workspace/editor_configurable.rb +113 -0
  70. data/lib/canopus/workspace/file_change_aware.rb +11 -3
  71. data/lib/canopus/workspace/git_aware.rb +80 -34
  72. data/lib/canopus/workspace/git_blame.rb +175 -0
  73. data/lib/canopus/workspace/git_conflict_resolution.rb +410 -0
  74. data/lib/canopus/workspace/git_diff_view.rb +641 -0
  75. data/lib/canopus/workspace/git_history.rb +171 -0
  76. data/lib/canopus/workspace/git_remote.rb +267 -0
  77. data/lib/canopus/workspace/git_staging.rb +583 -0
  78. data/lib/canopus/workspace/hierarchy_aware.rb +477 -0
  79. data/lib/canopus/workspace/keymap_aware.rb +66 -0
  80. data/lib/canopus/workspace/language_aware.rb +3721 -135
  81. data/lib/canopus/workspace/language_server_configurable.rb +287 -71
  82. data/lib/canopus/workspace/persistent_undo.rb +169 -0
  83. data/lib/canopus/workspace/problems_aware.rb +131 -0
  84. data/lib/canopus/workspace/project_searchable.rb +88 -11
  85. data/lib/canopus/workspace/project_tree_editable.rb +3 -1
  86. data/lib/canopus/workspace/recoverable.rb +204 -0
  87. data/lib/canopus/workspace/session_persistable.rb +85 -13
  88. data/lib/canopus/workspace/settings_aware.rb +76 -2
  89. data/lib/canopus/workspace/task_aware.rb +194 -0
  90. data/lib/canopus/workspace/test_aware.rb +394 -0
  91. data/lib/canopus/workspace/trust.rb +73 -0
  92. data/lib/canopus/workspace/view/terminal_presentable.rb +298 -47
  93. data/lib/canopus/workspace/view.rb +399 -74
  94. data/lib/canopus/workspace.rb +688 -44
  95. data/lib/canopus.rb +20 -2
  96. data/sig/canopus.rbs +138 -31
  97. data/sig/controller.rbs +10 -3
  98. data/sig/debug.rbs +121 -0
  99. data/sig/decoration.rbs +12 -2
  100. data/sig/diagnostics.rbs +26 -0
  101. data/sig/minimap.rbs +20 -0
  102. data/sig/provider.rbs +29 -0
  103. data/sig/services.rbs +1 -16
  104. data/sig/task.rbs +92 -0
  105. data/sig/test_runner.rbs +109 -0
  106. data/sig/trust.rbs +17 -0
  107. data/sig/workspace_edits.rbs +2 -2
  108. data/sig/workspace_services.rbs +234 -2
  109. data/tools/check_dependencies.rb +21 -2
  110. data/tools/package.rb +172 -14
  111. data/tools/package_signature_test.rb +33 -0
  112. data/tools/package_test.rb +29 -0
  113. data/tools/sign_package.rb +123 -0
  114. data/tools/update.rb +189 -0
  115. data/tools/update_test.rb +71 -0
  116. metadata +206 -31
  117. data/lib/canopus/lsp/client.rb +0 -307
  118. data/lib/canopus/lsp/error.rb +0 -7
  119. data/lib/canopus/lsp/future/subscription.rb +0 -9
  120. data/lib/canopus/lsp/future.rb +0 -87
  121. data/lib/canopus/lsp/protocol.rb +0 -83
  122. data/lib/canopus/lsp/server_error.rb +0 -13
  123. data/lib/canopus/lsp/timeout.rb +0 -7
  124. data/lib/canopus/lsp/transport.rb +0 -123
  125. data/lib/canopus/lsp.rb +0 -19
  126. data/lib/canopus/project/search.rb +0 -164
  127. data/lib/canopus/project/search_worker/cancelled.rb +0 -3
  128. data/lib/canopus/project/search_worker/runner.rb +0 -9
  129. data/lib/canopus/project/search_worker.rb +0 -108
  130. data/lib/canopus/terminal/cell.rb +0 -7
  131. data/lib/canopus/terminal/grid.rb +0 -321
  132. data/lib/canopus/terminal/pty.rb +0 -178
  133. data/lib/canopus/terminal/scrollback.rb +0 -38
  134. data/lib/canopus/terminal/vt.rb +0 -398
  135. data/lib/canopus/terminal.rb +0 -13
  136. data/sig/lsp.rbs +0 -99
  137. data/sig/terminal.rbs +0 -130
@@ -2,55 +2,110 @@
2
2
 
3
3
  require "digest"
4
4
  require "tempfile"
5
+ require "denebola"
6
+ require_relative "denebola_compat"
7
+ menkar_path = ENV["MENKAR_PATH"]
8
+ menkar_root = File.expand_path("../..", __dir__)
9
+ menkar_path ? require(File.expand_path("lib/menkar", File.expand_path(menkar_path, menkar_root))) : require("menkar")
5
10
 
6
11
  module Canopus
7
12
  class Buffer
8
13
  Transaction = Struct.new(:before, :after, :before_selections, :after_selections, :patch, :kind, :time)
9
14
  Anchor = Data.define(:id, :bias)
10
- attr_reader :rope, :path, :encoding, :line_ending, :version, :history, :read_only, :disk_digest, :bom, :notification_errors
15
+ attr_reader :rope, :path, :encoding, :line_ending, :newline, :version, :history, :read_only, :disk_digest,
16
+ :bom, :detection, :notification_errors
11
17
  attr_accessor :selections
12
18
 
13
- def self.open(path, large_file_threshold: 100 << 20)
19
+ def self.open(path, large_file_threshold: 100 << 20, encoding: nil)
14
20
  if File.size(path) >= large_file_threshold
21
+ detection = detect_file(path, encoding: encoding)
22
+ raise Error, "large-file mode requires UTF-8 text" unless detection.encoding == Encoding::UTF_8
15
23
  require_relative "lazy_rope"
16
- rope = LazyRope.new(path)
17
- return new("", path: path, read_only: true, rope: rope)
24
+ rope = Denebola::LazyRope.open(path, chunk_size: 65_536, cache_chunks: 32)
25
+ rope.line_count(exact: true)
26
+ return new("", path: path, read_only: true, rope: rope, encoding: detection.encoding,
27
+ bom: detection.bom, detection: detection)
18
28
  end
19
29
  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))
30
+ detection = detect_bytes(raw, encoding: encoding)
31
+ raise Error, "cannot edit binary file" if detection.binary
32
+ text = Menkar.decode(raw, detection)
33
+ new(text, path: path, encoding: detection.encoding, bom: detection.bom, detection: detection,
34
+ disk_digest: Digest::SHA256.hexdigest(raw))
35
+ rescue Menkar::Error => error
36
+ raise Error, error.message
22
37
  rescue EncodingError => error
23
38
  raise Error, "cannot decode #{path}: #{error.message}"
24
39
  end
25
40
 
41
+ def self.detect_file(path, encoding: nil)
42
+ return Menkar.detect_file(path) unless encoding
43
+
44
+ sample = File.open(path, "rb") { |file| file.read(Menkar::DEFAULT_SAMPLE + 1) || "".b }
45
+ detect_bytes(sample, encoding: encoding)
46
+ rescue Menkar::Error => error
47
+ raise Error, error.message
48
+ end
49
+ private_class_method :detect_file
50
+
51
+ def self.detect_bytes(raw, encoding: nil)
52
+ requested = encoding && Menkar.detect("".b, hint: encoding).encoding
53
+ detected = Menkar.detect(raw, hint: requested || bom_encoding(raw))
54
+ detected = legacy_japanese_detection(raw, detected) unless requested || !detected.bom.empty?
55
+ return detected unless requested
56
+ return detected unless detected.bom.empty?
57
+
58
+ Menkar.detect(raw, hint: requested).with(encoding: requested, confidence: 1.0, bom: "".b, binary: false)
59
+ end
60
+ private_class_method :detect_bytes
61
+
26
62
  # Decode worktree files and historical Git blobs by the same rules.
27
63
  # @return [Array(String, Encoding, String)] UTF-8 text, source encoding, BOM
28
64
  def self.decode_bytes(raw)
29
65
  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]
66
+ detection = detect_bytes(raw)
67
+ nul_binary = raw.include?("\0") && detection.bom.empty? && raw.bytesize < 8
68
+ raise Error, "binary file contains NUL bytes" if detection.binary || (detection.encoding.nil? && raw.include?("\0")) || nul_binary
69
+ [Menkar.decode(raw, detection), detection.encoding, detection.bom]
70
+ rescue Menkar::Error => error
71
+ raise Error, error.message
44
72
  end
45
73
 
46
- def initialize(text = "", path: nil, encoding: Encoding::UTF_8, bom: "".b, disk_digest: nil, read_only: false, draft: false, rope: nil)
74
+ def self.bom_encoding(raw)
75
+ return Encoding::UTF_32LE if raw.start_with?("\xFF\xFE\x00\x00".b)
76
+ return Encoding::UTF_32BE if raw.start_with?("\x00\x00\xFE\xFF".b)
77
+ return Encoding::UTF_16LE if raw.start_with?("\xFF\xFE".b)
78
+ return Encoding::UTF_16BE if raw.start_with?("\xFE\xFF".b)
79
+ nil
80
+ end
81
+ private_class_method :bom_encoding
82
+
83
+ def self.legacy_japanese_detection(raw, detected)
84
+ return detected unless detected.encoding == Encoding::Windows_1252 && raw.match?(/[^\x00-\x7F]/n)
85
+
86
+ candidate = Menkar.detect(raw, hint: Encoding::Windows_31J)
87
+ return detected unless candidate.encoding == Encoding::Windows_31J
88
+ text = Menkar.decode(raw, candidate)
89
+ text.match?(/[\p{Han}\p{Hiragana}\p{Katakana}]/) ? candidate : detected
90
+ rescue Menkar::Error, EncodingError
91
+ detected
92
+ end
93
+ private_class_method :legacy_japanese_detection
94
+
95
+ def initialize(text = "", path: nil, encoding: nil, bom: "".b, disk_digest: nil, read_only: false,
96
+ draft: false, rope: nil, detection: nil)
47
97
  @rope = rope || Denebola::Rope.new(text)
98
+ @save_mutex = Mutex.new
48
99
  @path = path && File.expand_path(path)
49
- @encoding, @bom, @disk_digest = encoding, bom, disk_digest
100
+ requested = encoding && Menkar.detect("".b, hint: encoding).encoding
101
+ @detection = detection || self.class.send(:detection_for_text, text, encoding: requested, bom: bom)
102
+ @encoding, @bom, @disk_digest = @detection.encoding || requested || Encoding::UTF_8,
103
+ @detection.bom.empty? ? bom : @detection.bom, disk_digest
50
104
  @saved_rope, @version, @history, @redo = @rope, 0, [], []
51
105
  @saved_rope = nil if draft
52
106
  @listeners, @anchors, @next_anchor, @selections = [], {}, 0, []
53
107
  @notification_errors = [].freeze
108
+ @newline = @detection.newline
54
109
  @line_ending = text[/\r\n|\n|\r|\u2028|\u2029/] || "\n"
55
110
  @line_ending = if rope.respond_to?(:line_ending)
56
111
  rope.line_ending
@@ -60,6 +115,8 @@ module Canopus
60
115
  @line_ending
61
116
  end
62
117
  @read_only = read_only
118
+ rescue Menkar::Error => error
119
+ raise Error, error.message
63
120
  end
64
121
  def text = rope.to_s
65
122
  def relocate(path) = @path = File.expand_path(path)
@@ -67,16 +124,16 @@ module Canopus
67
124
  def line(row) = rope.line(row)
68
125
  def line_count = rope.line_count
69
126
  def dirty? = !@rope.equal?(@saved_rope)
70
- def reload(force: false)
127
+ def reload(force: false, encoding: nil)
71
128
  raise SaveConflict, "buffer has unsaved changes" if dirty? && !force
72
129
  raise Error, "buffer has no file path" unless @path
73
130
  if @rope.respond_to?(:lazy?) && @rope.lazy?
74
- fresh = Buffer.open(@path, large_file_threshold: 0)
131
+ fresh = Buffer.open(@path, large_file_threshold: 0, encoding: encoding)
75
132
  old = @rope
76
133
  apply_snapshot(fresh.rope, Patch::Reload.new(old, fresh.rope))
77
134
  old.close
78
135
  else
79
- fresh = Buffer.open(@path)
136
+ fresh = Buffer.open(@path, encoding: encoding)
80
137
  raise Error, "file grew beyond the editable limit; reopen it read-only" if fresh.read_only
81
138
  before, after = text, fresh.text
82
139
  if before != after
@@ -92,7 +149,8 @@ module Canopus
92
149
  end
93
150
  end
94
151
  @saved_rope = @rope
95
- @disk_digest, @encoding, @bom, @line_ending = fresh.disk_digest, fresh.encoding, fresh.bom, fresh.line_ending
152
+ @disk_digest, @encoding, @bom, @line_ending, @newline, @detection = fresh.disk_digest, fresh.encoding, fresh.bom,
153
+ fresh.line_ending, fresh.newline, fresh.detection
96
154
  self
97
155
  ensure
98
156
  fresh&.close unless fresh&.rope.equal?(@rope)
@@ -184,36 +242,98 @@ module Canopus
184
242
  true
185
243
  end
186
244
 
245
+ # Persistent undo stores edits, not rope snapshots. Rebuilding from the
246
+ # current file backwards keeps records bounded while retaining Composite's
247
+ # intermediate coordinate spaces.
248
+ def persistent_undo_record(max_entries:)
249
+ entries = @history.last(max_entries)
250
+ return if entries.empty?
251
+ {"history" => entries.map { |transaction| persistent_undo_transaction(transaction) }}
252
+ end
253
+
254
+ def restore_persistent_undo!(record)
255
+ entries = record.fetch("history")
256
+ raise Error, "invalid persistent undo history" unless entries.is_a?(Array) && !entries.empty?
257
+ current = @rope
258
+ restored = entries.reverse_each.map do |entry|
259
+ raise Error, "invalid persistent undo transaction" unless entry.is_a?(Hash)
260
+ patch, before = persistent_undo_patch_before(entry.fetch("patch"), current)
261
+ before_selections = persistent_undo_selections(entry.fetch("before_selections"), before.bytesize)
262
+ after_selections = persistent_undo_selections(entry.fetch("after_selections"), current.bytesize)
263
+ kind = entry.fetch("kind")
264
+ raise Error, "invalid persistent undo kind" unless kind.is_a?(String) && kind.bytesize.between?(1, 128) && kind.match?(/\A[a-zA-Z0-9_:-]+\z/)
265
+ current = before
266
+ Transaction.new(before, patch.after, before_selections, after_selections, patch, kind.to_sym, 0.0)
267
+ end.reverse
268
+ @history.replace(restored)
269
+ @redo.clear
270
+ true
271
+ end
272
+
187
273
  # 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)
274
+ def save(path = @path, force: false, encoding: nil)
275
+ @save_mutex.synchronize do
276
+ raise Error, "no file path" unless path
277
+ raise Error, "buffer is read-only" if @read_only
278
+ destination = File.expand_path(path)
279
+ destination = File.realpath(destination) if File.symlink?(destination)
280
+ original = File.file?(destination) ? File.binread(destination) : nil
281
+ same_file = @path && (File.expand_path(path) == @path || (File.exist?(@path) && File.exist?(destination) && File.identical?(@path, destination)))
282
+ if !force && same_file && @disk_digest && (!original || Digest::SHA256.hexdigest(original) != @disk_digest)
283
+ raise SaveConflict, "file changed on disk: #{path}"
284
+ end
285
+ raise SaveConflict, "destination exists: #{path}" if !force && original && (!same_file || !@disk_digest)
286
+ snapshot = @rope
287
+ detection = encoding ? detection_for_encoding(encoding) : @detection
288
+ encoded = Menkar.encode(snapshot.to_s, detection)
289
+ Tempfile.create([".canopus-", ".tmp"], File.dirname(destination), binmode: true) do |file|
290
+ file.chmod(File.stat(destination).mode & 0o777) if original
291
+ file.write(encoded)
292
+ file.flush
293
+ file.fsync
294
+ file.close
295
+ current = File.file?(destination) ? File.binread(destination) : nil
296
+ raise SaveConflict, "file changed during save: #{path}" if !force && current != original
297
+ File.rename(file.path, destination)
298
+ end
299
+ @path = File.expand_path(path)
300
+ if encoding
301
+ @detection = detection
302
+ @encoding, @bom = detection.encoding, detection.bom
303
+ end
304
+ @saved_rope, @disk_digest = snapshot, Digest::SHA256.hexdigest(encoded)
305
+ self
209
306
  end
210
- @path = File.expand_path(path)
211
- @saved_rope, @disk_digest = @rope, Digest::SHA256.hexdigest(encoded)
212
- self
213
307
  rescue EncodingError => error
214
308
  raise Error, "cannot save using #{@encoding}: #{error.message}"
309
+ rescue Menkar::Error => error
310
+ raise Error, error.message
311
+ end
312
+
313
+ def roundtrip?(encoding: nil)
314
+ return true unless @detection && !@detection.binary
315
+
316
+ detection = encoding ? detection_for_encoding(encoding) : @detection
317
+ Menkar.roundtrip?(text, detection)
318
+ rescue Menkar::Error => error
319
+ raise Error, error.message
215
320
  end
216
321
 
322
+ def mixed_line_endings? = @newline == :mixed
323
+
324
+ def convert_line_endings(to: :lf)
325
+ normalized, = Menkar.normalize_newlines(text, to: to)
326
+ return false if normalized == text
327
+
328
+ edit([[0...rope.bytesize, normalized]], kind: :format)
329
+ @line_ending, @newline = {lf: "\n", crlf: "\r\n", cr: "\r"}.fetch(to), to
330
+ @detection = @detection.with(newline: to) if @detection.respond_to?(:with)
331
+ true
332
+ rescue Menkar::Error => error
333
+ raise Error, error.message
334
+ end
335
+ alias convert_newlines convert_line_endings
336
+
217
337
  def trim_trailing_whitespace
218
338
  changes = []
219
339
  rope.line_count.times do |row|
@@ -226,7 +346,128 @@ module Canopus
226
346
  edit(changes, kind: :format)
227
347
  end
228
348
 
349
+ def ensure_final_newline
350
+ return false if rope.empty? || text.end_with?("\n", "\r", "\u2028", "\u2029")
351
+ edit([[rope.bytesize...rope.bytesize, @line_ending]], kind: :format)
352
+ true
353
+ end
354
+
229
355
  private
356
+ def self.detection_for_text(text, encoding:, bom:)
357
+ detection = Menkar.detect(text.b, hint: encoding)
358
+ return detection if encoding.nil? && bom.empty?
359
+
360
+ detection.with(encoding: encoding || detection.encoding, bom: bom, binary: false)
361
+ end
362
+ private_class_method :detection_for_text
363
+
364
+ def detection_for_encoding(encoding)
365
+ encoding = Menkar.detect("".b, hint: encoding).encoding
366
+ bom = case encoding
367
+ when Encoding::UTF_16LE then "\xFF\xFE".b
368
+ when Encoding::UTF_16BE then "\xFE\xFF".b
369
+ when Encoding::UTF_32LE then "\xFF\xFE\x00\x00".b
370
+ when Encoding::UTF_32BE then "\x00\x00\xFE\xFF".b
371
+ else "".b
372
+ end
373
+ Menkar::Detection.new(encoding, 1.0, bom, @newline, @detection.indent, false)
374
+ rescue ArgumentError
375
+ raise Error, "unknown encoding: #{encoding}"
376
+ rescue Menkar::Error => error
377
+ raise Error, error.message
378
+ end
379
+ def persistent_undo_transaction(transaction)
380
+ {"kind" => transaction.kind.to_s,
381
+ "before_selections" => persistent_undo_selection_list(transaction.before_selections),
382
+ "after_selections" => persistent_undo_selection_list(transaction.after_selections),
383
+ "patch" => persistent_undo_patch(transaction.patch)}
384
+ end
385
+
386
+ def persistent_undo_selection_list(selections)
387
+ raise Error, "unsupported persistent undo selection" unless selections.is_a?(Array) && selections.length <= 10_000
388
+ selections.map do |selection|
389
+ values = [selection.id, selection.anchor, selection.head, selection.goal]
390
+ raise Error, "unsupported persistent undo selection" unless values[0].is_a?(Integer) && values[1].is_a?(Integer) && values[2].is_a?(Integer) && (values[3].nil? || values[3].is_a?(Integer))
391
+ {"id" => values[0], "anchor" => values[1], "head" => values[2], "goal" => values[3]}
392
+ end
393
+ end
394
+
395
+ def persistent_undo_patch(patch)
396
+ case patch
397
+ when Patch
398
+ {"type" => "patch", "edits" => patch.edits.map do |edit|
399
+ {"old_start" => edit.old_range.begin, "old_end" => edit.old_range.end,
400
+ "new_start" => edit.new_range.begin, "new_end" => edit.new_range.end,
401
+ "old_text" => edit.old_text, "new_text" => edit.new_text}
402
+ end}
403
+ when Patch::Composite
404
+ {"type" => "composite", "patches" => patch.patches.map { |child| persistent_undo_patch(child) }}
405
+ else
406
+ raise Error, "unsupported persistent undo patch"
407
+ end
408
+ end
409
+
410
+ def persistent_undo_patch_before(data, after)
411
+ raise Error, "invalid persistent undo patch" unless data.is_a?(Hash)
412
+ case data.fetch("type")
413
+ when "patch"
414
+ edits = persistent_undo_edits(data.fetch("edits"))
415
+ inverse = edits.map { |edit| [edit.fetch(:new_range), edit.fetch(:old_text)] }
416
+ before = after.apply_edits(inverse)
417
+ changes = edits.map { |edit| [edit.fetch(:old_range), edit.fetch(:new_text)] }
418
+ patch = Patch.new(before, after, changes)
419
+ persistent_undo_verify_patch!(patch, edits)
420
+ [patch, before]
421
+ when "composite"
422
+ children = data.fetch("patches")
423
+ raise Error, "invalid persistent undo composite" unless children.is_a?(Array) && !children.empty?
424
+ current = after
425
+ patches = children.reverse_each.map do |child|
426
+ patch, before = persistent_undo_patch_before(child, current)
427
+ current = before
428
+ patch
429
+ end.reverse
430
+ [Patch::Composite.new(patches), current]
431
+ else
432
+ raise Error, "invalid persistent undo patch type"
433
+ end
434
+ end
435
+
436
+ def persistent_undo_edits(values)
437
+ raise Error, "invalid persistent undo edits" unless values.is_a?(Array) && !values.empty? && values.length <= 10_000
438
+ edits = values.map do |value|
439
+ raise Error, "invalid persistent undo edit" unless value.is_a?(Hash)
440
+ old_start, old_end, new_start, new_end = %w[old_start old_end new_start new_end].map { |key| value.fetch(key) }
441
+ old_text, new_text = value.fetch("old_text"), value.fetch("new_text")
442
+ valid = [old_start, old_end, new_start, new_end].all? { |number| number.is_a?(Integer) && number >= 0 } &&
443
+ old_start <= old_end && new_start <= new_end && old_text.is_a?(String) && new_text.is_a?(String) &&
444
+ old_text.bytesize == old_end - old_start && new_text.bytesize == new_end - new_start
445
+ raise Error, "invalid persistent undo edit" unless valid
446
+ {old_range: old_start...old_end, new_range: new_start...new_end, old_text: old_text, new_text: new_text}
447
+ end
448
+ edits.each_cons(2) do |left, right|
449
+ raise Error, "overlapping persistent undo edits" if left.fetch(:old_range).end > right.fetch(:old_range).begin || left.fetch(:new_range).end > right.fetch(:new_range).begin
450
+ end
451
+ edits
452
+ end
453
+
454
+ def persistent_undo_verify_patch!(patch, expected)
455
+ actual = patch.edits.map { |edit| {old_range: edit.old_range, new_range: edit.new_range, old_text: edit.old_text, new_text: edit.new_text} }
456
+ raise Error, "persistent undo patch mismatch" unless actual == expected
457
+ patch
458
+ end
459
+
460
+ def persistent_undo_selections(values, bytesize)
461
+ raise Error, "invalid persistent undo selections" unless values.is_a?(Array) && values.length <= 10_000
462
+ values.map do |value|
463
+ raise Error, "invalid persistent undo selection" unless value.is_a?(Hash)
464
+ id, anchor, head, goal = %w[id anchor head goal].map { |key| value.fetch(key) }
465
+ valid = id.is_a?(Integer) && anchor.is_a?(Integer) && head.is_a?(Integer) && anchor.between?(0, bytesize) && head.between?(0, bytesize) && (goal.nil? || goal.is_a?(Integer))
466
+ raise Error, "invalid persistent undo selection" unless valid
467
+ Selection.new(id, anchor, head, goal)
468
+ end.freeze
469
+ end
470
+
230
471
  def snapshot_line_ending(rope)
231
472
  return "\n" if rope.line_count < 2
232
473
  ending = rope.line_start(1)
data/lib/canopus/cli.rb CHANGED
@@ -21,10 +21,11 @@ module Canopus
21
21
  flags.on("--profile JSON_PATH", "Save frame timings and sampled Ruby stacks on exit") { |value| options[:profile] = value }
22
22
  flags.on("--trace-allocations", "Trace live allocation sites (slow; requires --profile)") { options[:trace_allocations] = true }
23
23
  flags.on("--crash-report JSON_PATH", "Save exceptions locally; review before sharing") { |value| options[:crash_report] = value }
24
+ flags.on("--wait", "Wait until the opened files are closed") { options[:wait] = true }
24
25
  flags.on("--vim", "Enable Vim keybindings") { options[:vim] = true }
25
26
  flags.on("--plugin RUBY", "Load a Ruby plugin (requires --trust-plugins)") { |value| options[:plugins] << value }
26
27
  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("--grant PERMISSION", Plugins::PERMISSIONS, "Grant a plugin API permission (repeatable)") { |value| options[:grants] << value }
28
29
  flags.on("--plugins-in-process", "Run trusted plugins in the editor process") { options[:plugins_in_process] = true }
29
30
  flags.on("--version") { output.puts(VERSION); return 0 }
30
31
  flags.on("--help") { output.puts(flags); return 0 }
@@ -75,6 +76,8 @@ module Canopus
75
76
  workspace.performance = performance
76
77
  end
77
78
  controller = Controller.new(workspace, window)
79
+ controller.wait_for(files) if options[:wait] && !files.empty?
80
+ workspace.offer_recovery unless platform == :headless
78
81
  workspace.configure_text_system(cache_dir: options[:glyph_cache]) unless platform == :tui
79
82
  workspace.apply_settings
80
83
  warmup = Thread.new { window.text_system.prewarm(size: settings["font_size"]) } unless platform == :tui
@@ -148,7 +151,7 @@ module Canopus
148
151
  private_class_method :settle_export
149
152
 
150
153
  def self.finish_cli(options, workspace, window, performance, failure, error:)
151
- operations = [-> { performance&.stop }, -> { workspace&.close },
154
+ operations = [-> { performance&.stop }, -> { workspace&.preserve_recovery! if failure; workspace&.close },
152
155
  -> { window&.on_close { true }; window&.close },
153
156
  -> { performance&.write(options[:profile]) }]
154
157
  operations.each do |operation|
@@ -11,15 +11,20 @@ module Canopus
11
11
  "tab.close_all" => {"cmd-alt-w" => "", "ctrl-alt-w" => ""}.freeze,
12
12
  "tab.reopen_closed" => {"cmd-shift-t" => "", "ctrl-shift-t" => ""}.freeze,
13
13
  "file.find" => {"cmd-p" => "", "ctrl-p" => ""}.freeze,
14
+ "language.workspace_symbols" => {"cmd-t" => "", "ctrl-t" => ""}.freeze,
14
15
  "command.palette" => {"cmd-shift-p" => "", "ctrl-shift-p" => ""}.freeze,
15
16
  "edit.undo" => {"cmd-z" => "", "ctrl-z" => ""}.freeze,
16
17
  "edit.redo" => {"cmd-shift-z" => "", "ctrl-shift-z" => ""}.freeze,
17
18
  "edit.select_all" => {"cmd-a" => "", "ctrl-a" => ""}.freeze,
18
19
  "edit.select_next" => {"cmd-d" => "", "ctrl-d" => ""}.freeze,
20
+ "edit.add_cursor_up" => {"ctrl-alt-up" => ""}.freeze,
21
+ "edit.add_cursor_down" => {"ctrl-alt-down" => ""}.freeze,
19
22
  "search.buffer" => {"cmd-f" => "", "ctrl-f" => ""}.freeze,
20
23
  "edit.select_all_occurrences" => {"cmd-shift-l" => "", "ctrl-shift-l" => ""}.freeze,
21
- "edit.move_line_up" => {"alt-up" => ""}.freeze,
22
- "edit.move_line_down" => {"alt-down" => ""}.freeze,
24
+ "edit.move_line_up" => {"alt-shift-up" => ""}.freeze,
25
+ "edit.move_line_down" => {"alt-shift-down" => ""}.freeze,
26
+ "language.expand_selection" => {"alt-up" => ""}.freeze,
27
+ "language.shrink_selection" => {"alt-down" => ""}.freeze,
23
28
  "search.project" => {"cmd-shift-f" => "", "ctrl-shift-f" => ""}.freeze,
24
29
  "search.replace" => {"cmd-alt-f" => "", "ctrl-h" => ""}.freeze,
25
30
  "edit.toggle_comment" => {"cmd-/" => "", "ctrl-/" => ""}.freeze,
@@ -33,13 +38,17 @@ module Canopus
33
38
  "language.codeAction" => {"alt-enter" => ""}.freeze,
34
39
  "language.outline" => {"cmd-shift-o" => "", "ctrl-shift-o" => ""}.freeze,
35
40
  "language.hover" => {"cmd-k" => "", "ctrl-k" => ""}.freeze,
41
+ "task.run" => {"cmd-shift-b" => "", "ctrl-shift-b" => ""}.freeze,
42
+ "task.stop" => {"ctrl-c" => "TaskOutput"}.freeze,
36
43
  "terminal.toggle" => {"ctrl-`" => "Terminal"}.freeze,
37
44
  "terminal.new" => {"ctrl-shift-`" => ""}.freeze,
38
45
  "terminal.close" => {"cmd-w" => "Terminal", "ctrl-w" => "Terminal"}.freeze,
39
- "terminal.copy" => {"cmd-c" => "Terminal", "ctrl-shift-c" => "Terminal"}.freeze,
40
- "terminal.paste" => {"cmd-v" => "Terminal", "ctrl-shift-v" => "Terminal"}.freeze,
46
+ "terminal.copy" => {"cmd-c" => "Terminal || TaskOutput", "ctrl-shift-c" => "Terminal || TaskOutput"}.freeze,
47
+ "terminal.paste" => {"cmd-v" => "Terminal || TaskOutput", "ctrl-shift-v" => "Terminal || TaskOutput"}.freeze,
41
48
  "terminal.next" => {"ctrl-shift-]" => "Terminal"}.freeze,
42
49
  "terminal.prev" => {"ctrl-shift-[" => "Terminal"}.freeze,
50
+ "terminal.command.previous" => {"ctrl-shift-up" => "Terminal"}.freeze,
51
+ "terminal.command.next" => {"ctrl-shift-down" => "Terminal"}.freeze,
43
52
  "terminal.clear" => {"cmd-k" => "Terminal"}.freeze,
44
53
  **(1..9).to_h { |index| ["terminal.select_#{index}", {"cmd-#{index}" => "Terminal"}.freeze] }
45
54
  }.freeze