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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -0
- data/README.md +191 -19
- data/assets/keymaps/emacs.jsonc +5 -0
- data/assets/keymaps/jetbrains.jsonc +5 -0
- data/assets/keymaps/sublime.jsonc +5 -0
- data/assets/keymaps/vscode.jsonc +5 -0
- data/docs/debugging.md +143 -0
- data/docs/distribution.md +40 -9
- data/docs/lsp.md +131 -0
- data/docs/performance.md +18 -0
- data/docs/providers.md +36 -0
- data/docs/release-log.md +12 -0
- data/docs/tasks.md +64 -0
- data/docs/testing.md +49 -0
- data/docs/vim.md +3 -0
- data/exe/canopus +4 -1
- data/lib/canopus/buffer.rb +291 -50
- data/lib/canopus/cli.rb +5 -2
- data/lib/canopus/command.rb +13 -4
- data/lib/canopus/controller.rb +183 -40
- data/lib/canopus/debug/breakpoints.rb +550 -0
- data/lib/canopus/debug/configuration.rb +291 -0
- data/lib/canopus/debug/console.rb +243 -0
- data/lib/canopus/debug/panel.rb +487 -0
- data/lib/canopus/debug/session.rb +354 -0
- data/lib/canopus/decoration.rb +4 -2
- data/lib/canopus/denebola_compat.rb +27 -0
- data/lib/canopus/diagnostics.rb +173 -0
- data/lib/canopus/display_map/overlay_map.rb +6 -2
- data/lib/canopus/editor/snippet_expandable.rb +3 -2
- data/lib/canopus/editor.rb +122 -31
- data/lib/canopus/git/state.rb +100 -0
- data/lib/canopus/language/background_analysis.rb +93 -14
- data/lib/canopus/language/document.rb +70 -34
- data/lib/canopus/language/symbol.rb +1 -0
- data/lib/canopus/language/syntax_worker.rb +61 -9
- data/lib/canopus/language.rb +3 -0
- data/lib/canopus/minimap.rb +201 -0
- data/lib/canopus/patch.rb +3 -1
- data/lib/canopus/plugins/api.rb +4 -2
- data/lib/canopus/plugins/isolated_runtime.rb +64 -7
- data/lib/canopus/plugins/registry.rb +114 -3
- data/lib/canopus/plugins.rb +8 -7
- data/lib/canopus/project.rb +15 -6
- data/lib/canopus/provider.rb +192 -0
- data/lib/canopus/regexp_compat.rb +36 -4
- data/lib/canopus/selection.rb +12 -0
- data/lib/canopus/settings/schema.rb +201 -0
- data/lib/canopus/settings.rb +328 -49
- data/lib/canopus/task/configuration.rb +356 -0
- data/lib/canopus/task/problem_matcher.rb +264 -0
- data/lib/canopus/task/runner.rb +314 -0
- data/lib/canopus/test_runner/bounded_ignore.rb +144 -0
- data/lib/canopus/test_runner/discovery.rb +97 -0
- data/lib/canopus/test_runner/execution.rb +194 -0
- data/lib/canopus/test_runner/minitest.rb +116 -0
- data/lib/canopus/test_runner/rspec.rb +103 -0
- data/lib/canopus/test_runner/safe_walker.rb +37 -0
- data/lib/canopus/test_runner.rb +54 -0
- data/lib/canopus/theme/import.rb +131 -0
- data/lib/canopus/theme.rb +28 -4
- data/lib/canopus/version.rb +1 -1
- data/lib/canopus/vim/motionable.rb +1 -1
- data/lib/canopus/vim.rb +10 -16
- data/lib/canopus/workspace/auto_savable.rb +83 -0
- data/lib/canopus/workspace/debug_aware.rb +600 -0
- data/lib/canopus/workspace/edit/plan.rb +2 -2
- data/lib/canopus/workspace/editor_configurable.rb +113 -0
- data/lib/canopus/workspace/file_change_aware.rb +11 -3
- data/lib/canopus/workspace/git_aware.rb +80 -34
- data/lib/canopus/workspace/git_blame.rb +175 -0
- data/lib/canopus/workspace/git_conflict_resolution.rb +410 -0
- data/lib/canopus/workspace/git_diff_view.rb +641 -0
- data/lib/canopus/workspace/git_history.rb +171 -0
- data/lib/canopus/workspace/git_remote.rb +267 -0
- data/lib/canopus/workspace/git_staging.rb +583 -0
- data/lib/canopus/workspace/hierarchy_aware.rb +477 -0
- data/lib/canopus/workspace/keymap_aware.rb +66 -0
- data/lib/canopus/workspace/language_aware.rb +3721 -135
- data/lib/canopus/workspace/language_server_configurable.rb +287 -71
- data/lib/canopus/workspace/persistent_undo.rb +169 -0
- data/lib/canopus/workspace/problems_aware.rb +131 -0
- data/lib/canopus/workspace/project_searchable.rb +88 -11
- data/lib/canopus/workspace/project_tree_editable.rb +3 -1
- data/lib/canopus/workspace/recoverable.rb +204 -0
- data/lib/canopus/workspace/session_persistable.rb +85 -13
- data/lib/canopus/workspace/settings_aware.rb +76 -2
- data/lib/canopus/workspace/task_aware.rb +194 -0
- data/lib/canopus/workspace/test_aware.rb +394 -0
- data/lib/canopus/workspace/trust.rb +73 -0
- data/lib/canopus/workspace/view/terminal_presentable.rb +298 -47
- data/lib/canopus/workspace/view.rb +399 -74
- data/lib/canopus/workspace.rb +688 -44
- data/lib/canopus.rb +20 -2
- data/sig/canopus.rbs +138 -31
- data/sig/controller.rbs +10 -3
- data/sig/debug.rbs +121 -0
- data/sig/decoration.rbs +12 -2
- data/sig/diagnostics.rbs +26 -0
- data/sig/minimap.rbs +20 -0
- data/sig/provider.rbs +29 -0
- data/sig/services.rbs +1 -16
- data/sig/task.rbs +92 -0
- data/sig/test_runner.rbs +109 -0
- data/sig/trust.rbs +17 -0
- data/sig/workspace_edits.rbs +2 -2
- data/sig/workspace_services.rbs +234 -2
- data/tools/check_dependencies.rb +21 -2
- data/tools/package.rb +172 -14
- data/tools/package_signature_test.rb +33 -0
- data/tools/package_test.rb +29 -0
- data/tools/sign_package.rb +123 -0
- data/tools/update.rb +189 -0
- data/tools/update_test.rb +71 -0
- metadata +206 -31
- data/lib/canopus/lsp/client.rb +0 -307
- data/lib/canopus/lsp/error.rb +0 -7
- data/lib/canopus/lsp/future/subscription.rb +0 -9
- data/lib/canopus/lsp/future.rb +0 -87
- data/lib/canopus/lsp/protocol.rb +0 -83
- data/lib/canopus/lsp/server_error.rb +0 -13
- data/lib/canopus/lsp/timeout.rb +0 -7
- data/lib/canopus/lsp/transport.rb +0 -123
- data/lib/canopus/lsp.rb +0 -19
- data/lib/canopus/project/search.rb +0 -164
- data/lib/canopus/project/search_worker/cancelled.rb +0 -3
- data/lib/canopus/project/search_worker/runner.rb +0 -9
- data/lib/canopus/project/search_worker.rb +0 -108
- data/lib/canopus/terminal/cell.rb +0 -7
- data/lib/canopus/terminal/grid.rb +0 -321
- data/lib/canopus/terminal/pty.rb +0 -178
- data/lib/canopus/terminal/scrollback.rb +0 -38
- data/lib/canopus/terminal/vt.rb +0 -398
- data/lib/canopus/terminal.rb +0 -13
- data/sig/lsp.rbs +0 -99
- data/sig/terminal.rbs +0 -130
data/lib/canopus/buffer.rb
CHANGED
|
@@ -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,
|
|
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.
|
|
17
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
file
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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::
|
|
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|
|
data/lib/canopus/command.rb
CHANGED
|
@@ -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
|