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
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "rbconfig"
|
|
4
|
-
require_relative "search_worker"
|
|
5
|
-
|
|
6
|
-
class Canopus::Project::Search
|
|
7
|
-
SearchWorker = Canopus::Project.const_get(:SearchWorker, false)
|
|
8
|
-
private_constant :SearchWorker
|
|
9
|
-
|
|
10
|
-
Match = Struct.new(:path, :line, :column, :byte_offset, :text, :match, keyword_init: true)
|
|
11
|
-
DEFAULT_MAX_SIZE = 10 * 1024 * 1024
|
|
12
|
-
Child = Struct.new(:pid, :input, :output, :queue, :writer, :reader, :waiter)
|
|
13
|
-
private_constant :Child
|
|
14
|
-
|
|
15
|
-
def initialize(project)
|
|
16
|
-
@project = project
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
# Results are ordered by relative path and byte offset, even with workers.
|
|
20
|
-
def call(pattern, workers: 4, max_size: DEFAULT_MAX_SIZE, extensions: nil, limit: nil,
|
|
21
|
-
case_sensitive: true, cancelled: nil, paths: nil, &block)
|
|
22
|
-
raise ArgumentError, "workers must be between 1 and 32" unless workers.is_a?(Integer) && workers.between?(1, 32)
|
|
23
|
-
raise ArgumentError, "limit must be positive" if limit && (!limit.is_a?(Integer) || !limit.positive?)
|
|
24
|
-
raise ArgumentError, "max_size must be nonnegative" if max_size && (!max_size.is_a?(Integer) || max_size.negative?)
|
|
25
|
-
max_size = [max_size || SearchWorker::MAX_FILE_BYTES, SearchWorker::MAX_FILE_BYTES].min
|
|
26
|
-
expression = pattern.is_a?(Regexp) ? pattern : Regexp.new(Regexp.escape(pattern), case_sensitive ? 0 : Regexp::IGNORECASE)
|
|
27
|
-
expression = Regexp.new(expression.source, expression.options, timeout: expression.timeout || 0.25)
|
|
28
|
-
files = []
|
|
29
|
-
(paths || @project.files(extensions: extensions, max_size: max_size)).each do |relative|
|
|
30
|
-
SearchWorker.check_cancelled(cancelled)
|
|
31
|
-
raise ArgumentError, "invalid search path" unless relative.is_a?(String) && relative.valid_encoding? && !relative.include?("\0")
|
|
32
|
-
@project.path(relative)
|
|
33
|
-
files << relative
|
|
34
|
-
end
|
|
35
|
-
files.sort!
|
|
36
|
-
return [] if files.empty?
|
|
37
|
-
results = []
|
|
38
|
-
receive = receiver(results)
|
|
39
|
-
if workers > 1 && files.length > 1
|
|
40
|
-
parallel(files, expression, [workers, files.length].min, max_size, limit, cancelled, &receive)
|
|
41
|
-
else
|
|
42
|
-
SearchWorker.scan(@project.root, files, expression, max_size, limit, cancelled: cancelled, &receive)
|
|
43
|
-
end
|
|
44
|
-
SearchWorker.check_cancelled(cancelled)
|
|
45
|
-
results.each(&block) if block
|
|
46
|
-
results
|
|
47
|
-
rescue SearchWorker::Cancelled
|
|
48
|
-
[]
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
private
|
|
52
|
-
|
|
53
|
-
def receiver(results)
|
|
54
|
-
path = number = offset = raw = text = nil
|
|
55
|
-
lambda do |message|
|
|
56
|
-
case message[0]
|
|
57
|
-
when :line
|
|
58
|
-
_, path, number, offset, raw = message
|
|
59
|
-
text = raw.chomp.freeze
|
|
60
|
-
when :matches
|
|
61
|
-
message[1].each do |column, first, last|
|
|
62
|
-
results << Match.new(path: path, line: number, column: column,
|
|
63
|
-
byte_offset: offset + first, text: text, match: raw.byteslice(first, last - first))
|
|
64
|
-
end
|
|
65
|
-
when :error then raise IOError, "search worker: #{message[1]}"
|
|
66
|
-
when :done then nil
|
|
67
|
-
else raise IOError, "invalid search worker response"
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
def parallel(files, expression, count, max_size, limit, cancelled)
|
|
73
|
-
children, first = [], 0
|
|
74
|
-
count.times do |index|
|
|
75
|
-
SearchWorker.check_cancelled(cancelled)
|
|
76
|
-
length = files.length / count + (index < files.length % count ? 1 : 0)
|
|
77
|
-
config = [@project.root, files.slice(first, length), expression, max_size, limit, expression.timeout]
|
|
78
|
-
children << start_child(config)
|
|
79
|
-
first += length
|
|
80
|
-
end
|
|
81
|
-
remaining = limit
|
|
82
|
-
children.each do |child|
|
|
83
|
-
loop do
|
|
84
|
-
SearchWorker.check_cancelled(cancelled)
|
|
85
|
-
begin
|
|
86
|
-
message = child.queue.pop(true)
|
|
87
|
-
rescue ThreadError
|
|
88
|
-
sleep 0.005
|
|
89
|
-
next
|
|
90
|
-
end
|
|
91
|
-
raise message if message.is_a?(Exception)
|
|
92
|
-
break if message[0] == :done
|
|
93
|
-
if message[0] == :matches && remaining
|
|
94
|
-
message[1] = message[1].first(remaining)
|
|
95
|
-
remaining -= message[1].length
|
|
96
|
-
end
|
|
97
|
-
yield message
|
|
98
|
-
return if remaining == 0
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
ensure
|
|
102
|
-
children&.each { |child| stop_child(child) }
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def start_child(config)
|
|
106
|
-
child_input, input = IO.pipe
|
|
107
|
-
output, child_output = IO.pipe
|
|
108
|
-
child = Child.new(nil, input, output, SizedQueue.new(2))
|
|
109
|
-
child.pid = Process.spawn({"RUBYOPT" => nil, "RUBYLIB" => nil}, RbConfig.ruby, "--disable-gems",
|
|
110
|
-
File.expand_path("search_worker/runner.rb", __dir__), in: child_input, out: child_output, err: File::NULL)
|
|
111
|
-
child.waiter = Process.detach(child.pid)
|
|
112
|
-
child_input.close
|
|
113
|
-
child_output.close
|
|
114
|
-
child.writer = Thread.new do
|
|
115
|
-
SearchWorker.write_frame(input, config)
|
|
116
|
-
rescue StandardError => error
|
|
117
|
-
enqueue(child, error)
|
|
118
|
-
ensure
|
|
119
|
-
input.close unless input.closed?
|
|
120
|
-
end
|
|
121
|
-
child.reader = Thread.new do
|
|
122
|
-
loop do
|
|
123
|
-
message = SearchWorker.read_frame(output)
|
|
124
|
-
break unless enqueue(child, message)
|
|
125
|
-
break if message[0] == :done
|
|
126
|
-
end
|
|
127
|
-
rescue StandardError => error
|
|
128
|
-
enqueue(child, error)
|
|
129
|
-
end
|
|
130
|
-
child
|
|
131
|
-
rescue Exception
|
|
132
|
-
stop_child(child) if child
|
|
133
|
-
[child_input, child_output, input, output].compact.each { |io| io.close unless io.closed? }
|
|
134
|
-
raise
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
def stop_child(child)
|
|
138
|
-
child.queue.close
|
|
139
|
-
if child.waiter && !child.waiter.join(0)
|
|
140
|
-
signal(child, "TERM")
|
|
141
|
-
unless child.waiter.join(0.2)
|
|
142
|
-
signal(child, "KILL")
|
|
143
|
-
child.waiter.join(0.5)
|
|
144
|
-
end
|
|
145
|
-
end
|
|
146
|
-
[child.input, child.output].compact.each { |io| io.close unless io.closed? }
|
|
147
|
-
[child.writer, child.reader].compact.each do |thread|
|
|
148
|
-
thread.kill unless thread.join(0.2)
|
|
149
|
-
thread.join(0.1)
|
|
150
|
-
end
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
def enqueue(child, message)
|
|
154
|
-
child.queue.push(message)
|
|
155
|
-
rescue ClosedQueueError
|
|
156
|
-
false
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
def signal(child, name)
|
|
160
|
-
Process.kill(name, child.pid)
|
|
161
|
-
rescue Errno::ESRCH, Errno::EINVAL
|
|
162
|
-
nil
|
|
163
|
-
end
|
|
164
|
-
end
|
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "../regexp_compat"
|
|
4
|
-
require_relative "../match_data_compat"
|
|
5
|
-
|
|
6
|
-
# This protocol only connects this file to its own Ruby parent. Marshal is
|
|
7
|
-
# never accepted from a file, network peer, plugin, or external executable.
|
|
8
|
-
module Canopus::Project::SearchWorker
|
|
9
|
-
MAX_FILE_BYTES = 64 << 20
|
|
10
|
-
MAX_FRAME_BYTES = MAX_FILE_BYTES + (64 << 10)
|
|
11
|
-
READ_BYTES = 64 << 10
|
|
12
|
-
MATCH_BATCH = 128
|
|
13
|
-
|
|
14
|
-
def self.check_cancelled(callback)
|
|
15
|
-
raise Cancelled if callback&.call
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def self.write_frame(io, value)
|
|
19
|
-
bytes = Marshal.dump(value)
|
|
20
|
-
raise IOError, "search frame exceeds safety limit" if bytes.bytesize > MAX_FRAME_BYTES
|
|
21
|
-
io.write([bytes.bytesize].pack("N"))
|
|
22
|
-
io.write(bytes)
|
|
23
|
-
io.flush
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def self.read_frame(io)
|
|
27
|
-
header = io.read(4)
|
|
28
|
-
raise EOFError, "search worker ended before completion" unless header && header.bytesize == 4
|
|
29
|
-
size = header.unpack1("N")
|
|
30
|
-
raise IOError, "invalid search frame size" unless size.between?(1, MAX_FRAME_BYTES)
|
|
31
|
-
bytes = io.read(size)
|
|
32
|
-
raise EOFError, "truncated search frame" unless bytes && bytes.bytesize == size
|
|
33
|
-
Marshal.load(bytes)
|
|
34
|
-
rescue TypeError, ArgumentError => error
|
|
35
|
-
raise IOError, "invalid search frame: #{error.message}"
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def self.scan(root, paths, expression, max_size, limit, cancelled: nil)
|
|
39
|
-
total = 0
|
|
40
|
-
paths.each do |relative|
|
|
41
|
-
check_cancelled(cancelled)
|
|
42
|
-
absolute = File.expand_path(relative, root)
|
|
43
|
-
raise ArgumentError, "path outside project" unless absolute.start_with?(root + File::SEPARATOR)
|
|
44
|
-
source = read_source(absolute, max_size, cancelled)
|
|
45
|
-
next unless source
|
|
46
|
-
offset = 0
|
|
47
|
-
source.each_line.with_index(1) do |line, number|
|
|
48
|
-
check_cancelled(cancelled)
|
|
49
|
-
matches, sent_line = [], false
|
|
50
|
-
Canopus.with_regexp_timeout(expression) do
|
|
51
|
-
line.to_enum(:scan, expression).each do
|
|
52
|
-
match = Regexp.last_match
|
|
53
|
-
check_cancelled(cancelled)
|
|
54
|
-
unless sent_line
|
|
55
|
-
yield [:line, relative, number, offset, line]
|
|
56
|
-
sent_line = true
|
|
57
|
-
end
|
|
58
|
-
first = match.respond_to?(:bytebegin) ? match.bytebegin(0) : match.pre_match.bytesize
|
|
59
|
-
matches << [match.begin(0) + 1, first, first + match[0].bytesize]
|
|
60
|
-
total += 1
|
|
61
|
-
if matches.length == MATCH_BATCH || (limit && total >= limit)
|
|
62
|
-
yield [:matches, matches]
|
|
63
|
-
matches = []
|
|
64
|
-
end
|
|
65
|
-
return if limit && total >= limit
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
yield [:matches, matches] unless matches.empty?
|
|
69
|
-
offset += line.bytesize
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def self.read_source(path, max_size, cancelled)
|
|
75
|
-
flags = File::RDONLY
|
|
76
|
-
# Windows defines NONBLOCK as 1, which aliases WRONLY in File.open.
|
|
77
|
-
flags |= File::NONBLOCK unless RUBY_PLATFORM.match?(/mswin|mingw/)
|
|
78
|
-
File.open(path, flags) do |file|
|
|
79
|
-
file.binmode
|
|
80
|
-
return unless file.stat.file? && file.size <= max_size
|
|
81
|
-
source = +"".b
|
|
82
|
-
while (chunk = file.read(READ_BYTES))
|
|
83
|
-
check_cancelled(cancelled)
|
|
84
|
-
source << chunk
|
|
85
|
-
return if source.bytesize > max_size || chunk.include?("\0")
|
|
86
|
-
end
|
|
87
|
-
source.force_encoding(Encoding::UTF_8)
|
|
88
|
-
source if source.valid_encoding?
|
|
89
|
-
end
|
|
90
|
-
rescue Errno::ENOENT, Errno::EACCES, Errno::EISDIR, Errno::ELOOP
|
|
91
|
-
nil
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def self.run
|
|
95
|
-
STDIN.binmode
|
|
96
|
-
STDOUT.binmode
|
|
97
|
-
root, paths, expression, max_size, limit, timeout = read_frame(STDIN)
|
|
98
|
-
expression = Regexp.new(expression.source, expression.options, timeout: timeout)
|
|
99
|
-
scan(root, paths, expression, max_size, limit) { |message| write_frame(STDOUT, message) }
|
|
100
|
-
write_frame(STDOUT, [:done])
|
|
101
|
-
rescue StandardError => error
|
|
102
|
-
write_frame(STDOUT, [:error, "#{error.class}: #{error.message}".byteslice(0, 4096)])
|
|
103
|
-
write_frame(STDOUT, [:done])
|
|
104
|
-
end
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
require_relative "search_worker/cancelled"
|
|
108
|
-
Canopus::Project.send(:private_constant, :SearchWorker)
|
|
@@ -1,321 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "denebola"
|
|
4
|
-
require "zaniah"
|
|
5
|
-
|
|
6
|
-
module Canopus
|
|
7
|
-
module Terminal
|
|
8
|
-
class Grid
|
|
9
|
-
attr_reader :columns, :rows, :cells, :scrollback, :cursor_x, :cursor_y, :scroll_top, :scroll_bottom
|
|
10
|
-
attr_accessor :foreground, :background, :attributes, :hyperlink, :autowrap, :insert_mode, :origin_mode, :cursor_visible
|
|
11
|
-
|
|
12
|
-
def initialize(columns: 80, rows: 24, scrollback: 10_000)
|
|
13
|
-
validate_dimensions(columns, rows)
|
|
14
|
-
@columns, @rows = columns, rows
|
|
15
|
-
@scrollback = Scrollback.new(scrollback)
|
|
16
|
-
reset
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def reset
|
|
20
|
-
@foreground = @background = @hyperlink = nil
|
|
21
|
-
@attributes = {}.freeze
|
|
22
|
-
@autowrap = @cursor_visible = true
|
|
23
|
-
@insert_mode = @origin_mode = false
|
|
24
|
-
@cursor_x = @cursor_y = 0
|
|
25
|
-
@wrap_pending = false
|
|
26
|
-
@alternate = nil
|
|
27
|
-
@scroll_top, @scroll_bottom = 0, rows - 1
|
|
28
|
-
@cells = Array.new(rows) { blank_row }
|
|
29
|
-
@tabs = (8...columns).step(8).to_a
|
|
30
|
-
save_cursor
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def [](row, column = nil) = column ? cells[row]&.[](column) : cells[row]
|
|
34
|
-
def alternate? = !@alternate.nil?
|
|
35
|
-
def lines = cells.map { |row| row.map(&:text).join.rstrip }
|
|
36
|
-
def text = lines.join("\n")
|
|
37
|
-
|
|
38
|
-
def put(char)
|
|
39
|
-
width = self.class.width(char)
|
|
40
|
-
previous_x = @wrap_pending ? cursor_x : cursor_x - 1
|
|
41
|
-
if previous_x >= 0 && !char.ascii_only?
|
|
42
|
-
previous_x -= 1 if cells[cursor_y][previous_x].width.zero? && previous_x.positive?
|
|
43
|
-
previous = cells[cursor_y][previous_x]
|
|
44
|
-
joined = previous.text + char
|
|
45
|
-
if joined.match?(/\A\X\z/)
|
|
46
|
-
width = [self.class.width(joined), columns].min
|
|
47
|
-
if previous_x + width > columns && autowrap
|
|
48
|
-
cells[cursor_y][previous_x] = blank
|
|
49
|
-
carriage_return
|
|
50
|
-
linefeed
|
|
51
|
-
return put(joined)
|
|
52
|
-
end
|
|
53
|
-
width = [width, columns - previous_x].min
|
|
54
|
-
clear_wide(cursor_y, previous_x + 1) if width == 2 && previous.width < 2
|
|
55
|
-
cells[cursor_y][previous_x + 1] = blank if width < previous.width
|
|
56
|
-
previous.text, previous.width = joined, width
|
|
57
|
-
cells[cursor_y][previous_x + 1] = previous.dup.tap { |cell| cell.text = ""; cell.width = 0 } if width == 2
|
|
58
|
-
@cursor_x = [previous_x + width, columns - 1].min
|
|
59
|
-
@wrap_pending = previous_x + width >= columns && autowrap
|
|
60
|
-
return
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
return if width.zero?
|
|
64
|
-
width = 1 if width > columns
|
|
65
|
-
if @wrap_pending || (width == 2 && cursor_x == columns - 1)
|
|
66
|
-
if autowrap
|
|
67
|
-
@cursor_x = 0
|
|
68
|
-
linefeed
|
|
69
|
-
elsif width == 2
|
|
70
|
-
return
|
|
71
|
-
end
|
|
72
|
-
@wrap_pending = false
|
|
73
|
-
end
|
|
74
|
-
insert_characters(width) if insert_mode
|
|
75
|
-
clear_wide(cursor_y, cursor_x)
|
|
76
|
-
clear_wide(cursor_y, cursor_x + 1) if width == 2
|
|
77
|
-
cells[cursor_y][cursor_x] = Cell.new(text: char, width: width, foreground: foreground,
|
|
78
|
-
background: background, attributes: attributes, hyperlink: hyperlink)
|
|
79
|
-
cells[cursor_y][cursor_x + 1] = Cell.new(text: "", width: 0, foreground: foreground,
|
|
80
|
-
background: background, attributes: attributes, hyperlink: hyperlink) if width == 2
|
|
81
|
-
if cursor_x + width >= columns
|
|
82
|
-
@cursor_x = columns - 1
|
|
83
|
-
@wrap_pending = autowrap
|
|
84
|
-
else
|
|
85
|
-
@cursor_x += width
|
|
86
|
-
end
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
def self.width(char)
|
|
90
|
-
return 1 if char.bytesize == 1 && char.ord.between?(32, 126)
|
|
91
|
-
Zaniah::Unicode.width(char)
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def move(x: cursor_x, y: cursor_y, relative: false)
|
|
95
|
-
if relative
|
|
96
|
-
x += cursor_x
|
|
97
|
-
y += cursor_y
|
|
98
|
-
end
|
|
99
|
-
top, bottom = origin_mode ? [scroll_top, scroll_bottom] : [0, rows - 1]
|
|
100
|
-
@cursor_x = x.clamp(0, columns - 1)
|
|
101
|
-
@cursor_y = y.clamp(top, bottom)
|
|
102
|
-
@wrap_pending = false
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def position(row, column)
|
|
106
|
-
move(x: column - 1, y: row - 1 + (origin_mode ? scroll_top : 0))
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
def carriage_return = move(x: 0)
|
|
110
|
-
def backspace = move(x: cursor_x - 1)
|
|
111
|
-
|
|
112
|
-
def linefeed
|
|
113
|
-
@wrap_pending = false
|
|
114
|
-
if cursor_y == scroll_bottom
|
|
115
|
-
scroll_up
|
|
116
|
-
elsif cursor_y < rows - 1
|
|
117
|
-
@cursor_y += 1
|
|
118
|
-
end
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
def reverse_index
|
|
122
|
-
@wrap_pending = false
|
|
123
|
-
cursor_y == scroll_top ? scroll_down : @cursor_y = [0, cursor_y - 1].max
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def tab(count = 1, backward: false)
|
|
127
|
-
count.times do
|
|
128
|
-
stop = backward ? @tabs.reverse.find { |column| column < cursor_x } : @tabs.find { |column| column > cursor_x }
|
|
129
|
-
move(x: stop || (backward ? 0 : columns - 1))
|
|
130
|
-
end
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
def tab_set = @tabs = (@tabs + [cursor_x]).uniq.sort
|
|
134
|
-
def tab_clear(all: false) = all ? @tabs.clear : @tabs.delete(cursor_x)
|
|
135
|
-
|
|
136
|
-
def margins(top = 1, bottom = rows)
|
|
137
|
-
return unless top >= 1 && top < bottom && bottom <= rows
|
|
138
|
-
@scroll_top, @scroll_bottom = top - 1, bottom - 1
|
|
139
|
-
position(1, 1)
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def scroll_up(count = 1)
|
|
143
|
-
[count, scroll_bottom - scroll_top + 1].min.times do
|
|
144
|
-
removed = cells.delete_at(scroll_top)
|
|
145
|
-
scrollback.push(removed) if scroll_top.zero? && scroll_bottom == rows - 1 && !alternate?
|
|
146
|
-
cells.insert(scroll_bottom, blank_row)
|
|
147
|
-
end
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
def scroll_down(count = 1)
|
|
151
|
-
[count, scroll_bottom - scroll_top + 1].min.times do
|
|
152
|
-
cells.delete_at(scroll_bottom)
|
|
153
|
-
cells.insert(scroll_top, blank_row)
|
|
154
|
-
end
|
|
155
|
-
end
|
|
156
|
-
|
|
157
|
-
def erase_display(mode = 0)
|
|
158
|
-
case mode
|
|
159
|
-
when 0
|
|
160
|
-
erase_line(0)
|
|
161
|
-
((cursor_y + 1)...rows).each { |row| cells[row] = blank_row }
|
|
162
|
-
when 1
|
|
163
|
-
(0...cursor_y).each { |row| cells[row] = blank_row }
|
|
164
|
-
erase_line(1)
|
|
165
|
-
when 2 then @cells = Array.new(rows) { blank_row }
|
|
166
|
-
when 3 then scrollback.clear
|
|
167
|
-
end
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
def erase_line(mode = 0)
|
|
171
|
-
first, last = case mode
|
|
172
|
-
when 0 then [cursor_x, columns - 1]
|
|
173
|
-
when 1 then [0, cursor_x]
|
|
174
|
-
when 2 then [0, columns - 1]
|
|
175
|
-
else return
|
|
176
|
-
end
|
|
177
|
-
(first..last).each { |column| clear_wide(cursor_y, column); cells[cursor_y][column] = blank }
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
def erase_characters(count = 1)
|
|
181
|
-
(cursor_x...[cursor_x + count, columns].min).each { |column| clear_wide(cursor_y, column); cells[cursor_y][column] = blank }
|
|
182
|
-
end
|
|
183
|
-
|
|
184
|
-
def insert_characters(count = 1)
|
|
185
|
-
clear_wide(cursor_y, cursor_x) if cells[cursor_y][cursor_x].width.zero?
|
|
186
|
-
cells[cursor_y].insert(cursor_x, *Array.new([count, columns - cursor_x].min) { blank })
|
|
187
|
-
cells[cursor_y] = cells[cursor_y].first(columns)
|
|
188
|
-
normalize_row(cursor_y)
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
def delete_characters(count = 1)
|
|
192
|
-
clear_wide(cursor_y, cursor_x)
|
|
193
|
-
count = [count, columns - cursor_x].min
|
|
194
|
-
cells[cursor_y].slice!(cursor_x, count)
|
|
195
|
-
cells[cursor_y].concat(Array.new(count) { blank })
|
|
196
|
-
normalize_row(cursor_y)
|
|
197
|
-
end
|
|
198
|
-
|
|
199
|
-
def insert_lines(count = 1)
|
|
200
|
-
return unless cursor_y.between?(scroll_top, scroll_bottom)
|
|
201
|
-
[count, scroll_bottom - cursor_y + 1].min.times { cells.delete_at(scroll_bottom); cells.insert(cursor_y, blank_row) }
|
|
202
|
-
end
|
|
203
|
-
|
|
204
|
-
def delete_lines(count = 1)
|
|
205
|
-
return unless cursor_y.between?(scroll_top, scroll_bottom)
|
|
206
|
-
[count, scroll_bottom - cursor_y + 1].min.times { cells.delete_at(cursor_y); cells.insert(scroll_bottom, blank_row) }
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
def save_cursor
|
|
210
|
-
@saved = [cursor_x, cursor_y, foreground, background, attributes, hyperlink, origin_mode, @wrap_pending]
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
def restore_cursor
|
|
214
|
-
return unless @saved
|
|
215
|
-
@cursor_x, @cursor_y, @foreground, @background, @attributes, @hyperlink, @origin_mode, @wrap_pending = @saved
|
|
216
|
-
@cursor_x = cursor_x.clamp(0, columns - 1)
|
|
217
|
-
@cursor_y = cursor_y.clamp(0, rows - 1)
|
|
218
|
-
end
|
|
219
|
-
|
|
220
|
-
def alternate(enable, save: true)
|
|
221
|
-
if enable && !alternate?
|
|
222
|
-
save_cursor if save
|
|
223
|
-
@alternate = cells
|
|
224
|
-
@cells = Array.new(rows) { blank_row }
|
|
225
|
-
@cursor_x = @cursor_y = 0
|
|
226
|
-
@scroll_top, @scroll_bottom = 0, rows - 1
|
|
227
|
-
@wrap_pending = false
|
|
228
|
-
elsif !enable && alternate?
|
|
229
|
-
@cells, @alternate = @alternate, nil
|
|
230
|
-
@scroll_top, @scroll_bottom = 0, rows - 1
|
|
231
|
-
restore_cursor if save
|
|
232
|
-
end
|
|
233
|
-
end
|
|
234
|
-
|
|
235
|
-
def resize(columns:, rows:)
|
|
236
|
-
validate_dimensions(columns, rows)
|
|
237
|
-
previous_columns = @columns
|
|
238
|
-
@columns, @rows = columns, rows
|
|
239
|
-
[cells, @alternate].compact.each do |screen|
|
|
240
|
-
while screen.length > rows
|
|
241
|
-
removed = screen.shift
|
|
242
|
-
scrollback.push(removed) unless alternate?
|
|
243
|
-
@cursor_y -= 1 if screen.equal?(cells)
|
|
244
|
-
end
|
|
245
|
-
screen << blank_row while screen.length < rows
|
|
246
|
-
screen.each do |row|
|
|
247
|
-
row.slice!(columns, row.length) if row.length > columns
|
|
248
|
-
row << blank while row.length < columns
|
|
249
|
-
end
|
|
250
|
-
end
|
|
251
|
-
cells.each_index { |index| normalize_row(index) }
|
|
252
|
-
@tabs = (@tabs.select { |column| column < columns } + (previous_columns...columns).select { |column| column.positive? && (column % 8).zero? }).uniq.sort
|
|
253
|
-
@scroll_top, @scroll_bottom = 0, rows - 1
|
|
254
|
-
move
|
|
255
|
-
end
|
|
256
|
-
|
|
257
|
-
# Coordinates are [column, row]; finish is exclusive, including scrollback
|
|
258
|
-
# when history: true. This is suitable for selection/copy without a renderer.
|
|
259
|
-
def selection(start, finish, history: false)
|
|
260
|
-
source = history ? scrollback.to_a + cells : cells
|
|
261
|
-
start, finish = finish, start if ([start[1], start[0]] <=> [finish[1], finish[0]]) == 1
|
|
262
|
-
(start[1]..finish[1]).map do |row|
|
|
263
|
-
next "" unless source[row]
|
|
264
|
-
first = row == start[1] ? start[0] : 0
|
|
265
|
-
last = row == finish[1] ? finish[0] : columns
|
|
266
|
-
source[row][first...last].to_a.map(&:text).join.rstrip
|
|
267
|
-
end.join("\n")
|
|
268
|
-
end
|
|
269
|
-
|
|
270
|
-
def links(row)
|
|
271
|
-
line = cells.fetch(row).map(&:text).join
|
|
272
|
-
explicit = []
|
|
273
|
-
cells[row].each_with_index do |cell, column|
|
|
274
|
-
next unless cell.hyperlink
|
|
275
|
-
if explicit.last && explicit.last[:url] == cell.hyperlink && explicit.last[:end_column] == column
|
|
276
|
-
explicit.last[:end_column] = column + 1
|
|
277
|
-
else
|
|
278
|
-
explicit << {url: cell.hyperlink, column: column, end_column: column + 1}
|
|
279
|
-
end
|
|
280
|
-
end
|
|
281
|
-
line.to_enum(:scan, %r{https?://[^\s<>"']+}).each do
|
|
282
|
-
found = Regexp.last_match
|
|
283
|
-
column = line[0...found.begin(0)].each_char.sum { |char| self.class.width(char) }
|
|
284
|
-
explicit << {url: found[0], column: column, end_column: column + found[0].length}
|
|
285
|
-
end
|
|
286
|
-
explicit.uniq
|
|
287
|
-
end
|
|
288
|
-
|
|
289
|
-
def file_paths(row, cwd: Dir.pwd)
|
|
290
|
-
line = cells.fetch(row).map(&:text).join
|
|
291
|
-
line.scan(%r{(?:\A|[\s("'])([^\s:"'()]+):(\d+)(?::(\d+))?}).filter_map do |name, number, column|
|
|
292
|
-
absolute = File.expand_path(name, cwd)
|
|
293
|
-
{path: absolute, line: number.to_i, column: column ? column.to_i : 1} if File.file?(absolute)
|
|
294
|
-
end
|
|
295
|
-
end
|
|
296
|
-
|
|
297
|
-
private
|
|
298
|
-
|
|
299
|
-
def validate_dimensions(columns, rows)
|
|
300
|
-
raise ArgumentError, "terminal dimensions must be positive integers" unless columns.is_a?(Integer) && rows.is_a?(Integer) && columns.positive? && rows.positive?
|
|
301
|
-
end
|
|
302
|
-
|
|
303
|
-
def blank = Cell.new(text: " ", width: 1, foreground: foreground, background: background, attributes: {}.freeze)
|
|
304
|
-
def blank_row = Array.new(columns) { blank }
|
|
305
|
-
|
|
306
|
-
def clear_wide(row, column)
|
|
307
|
-
return unless column.between?(0, columns - 1)
|
|
308
|
-
cell = cells[row][column]
|
|
309
|
-
cells[row][column - 1] = blank if cell.width.zero? && column.positive?
|
|
310
|
-
cells[row][column + 1] = blank if cell.width == 2 && column + 1 < columns
|
|
311
|
-
end
|
|
312
|
-
|
|
313
|
-
def normalize_row(row)
|
|
314
|
-
cells[row].each_with_index do |cell, column|
|
|
315
|
-
cells[row][column] = blank if (cell.width == 2 && (column == columns - 1 || cells[row][column + 1].width != 0)) ||
|
|
316
|
-
(cell.width.zero? && (column.zero? || cells[row][column - 1].width != 2))
|
|
317
|
-
end
|
|
318
|
-
end
|
|
319
|
-
end
|
|
320
|
-
end
|
|
321
|
-
end
|