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
@@ -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,3 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class Canopus::Project::SearchWorker::Cancelled < StandardError; end
@@ -1,9 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Canopus
4
- Project = Class.new
5
- end
6
-
7
- require_relative "../search_worker"
8
-
9
- Canopus::Project.const_get(:SearchWorker, false).run
@@ -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,7 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Canopus
4
- module Terminal
5
- Cell = Struct.new(:text, :width, :foreground, :background, :attributes, :hyperlink, keyword_init: true)
6
- end
7
- end
@@ -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