canopus 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +148 -0
- data/docs/adr/001-runtime-gem-components.md +24 -0
- data/docs/adr/002-bounded-diff-engine.md +22 -0
- data/docs/adr/003-background-language-analysis.md +22 -0
- data/docs/adr/004-persistent-display-map.md +23 -0
- data/docs/adr/005-windows-runtime.md +27 -0
- data/docs/adr/README.md +36 -0
- data/docs/distribution.md +27 -0
- data/docs/lsp.md +38 -0
- data/docs/performance.md +40 -0
- data/docs/snippets.md +32 -0
- data/docs/vim.md +24 -0
- data/docs/workspace_edits.md +32 -0
- data/examples/native_smoke.rb +25 -0
- data/examples/plugins/word_count.rb +11 -0
- data/exe/canopus +19 -0
- data/lib/canopus/block_map.rb +19 -0
- data/lib/canopus/buffer.rb +293 -0
- data/lib/canopus/cli.rb +171 -0
- data/lib/canopus/controller.rb +507 -0
- data/lib/canopus/data_compat.rb +25 -0
- data/lib/canopus/display_map/line_builder.rb +54 -0
- data/lib/canopus/display_map/line_set.rb +8 -0
- data/lib/canopus/display_map/pending_line_set.rb +9 -0
- data/lib/canopus/display_map/summary.rb +23 -0
- data/lib/canopus/display_map/worker.rb +89 -0
- data/lib/canopus/display_map.rb +350 -0
- data/lib/canopus/display_point.rb +5 -0
- data/lib/canopus/editor/snippet_expandable.rb +281 -0
- data/lib/canopus/editor.rb +445 -0
- data/lib/canopus/error.rb +5 -0
- data/lib/canopus/fold_map.rb +87 -0
- data/lib/canopus/git/blame.rb +50 -0
- data/lib/canopus/git/commit.rb +7 -0
- data/lib/canopus/git/corrupt_object.rb +7 -0
- data/lib/canopus/git/diff.rb +131 -0
- data/lib/canopus/git/index.rb +94 -0
- data/lib/canopus/git/object_database.rb +45 -0
- data/lib/canopus/git/pack.rb +186 -0
- data/lib/canopus/git/repository.rb +313 -0
- data/lib/canopus/git/status.rb +74 -0
- data/lib/canopus/git/tree_entry.rb +7 -0
- data/lib/canopus/git.rb +15 -0
- data/lib/canopus/icon_theme.rb +43 -0
- data/lib/canopus/language/background_analysis/job.rb +12 -0
- data/lib/canopus/language/background_analysis/scheduler.rb +70 -0
- data/lib/canopus/language/background_analysis.rb +280 -0
- data/lib/canopus/language/definition.rb +7 -0
- data/lib/canopus/language/document.rb +143 -0
- data/lib/canopus/language/symbol.rb +7 -0
- data/lib/canopus/language/syntax_worker.rb +91 -0
- data/lib/canopus/language.rb +42 -0
- data/lib/canopus/lazy_rope.rb +218 -0
- data/lib/canopus/lsp/client.rb +299 -0
- data/lib/canopus/lsp/error.rb +7 -0
- data/lib/canopus/lsp/future/subscription.rb +9 -0
- data/lib/canopus/lsp/future.rb +87 -0
- data/lib/canopus/lsp/protocol.rb +83 -0
- data/lib/canopus/lsp/server_error.rb +13 -0
- data/lib/canopus/lsp/timeout.rb +7 -0
- data/lib/canopus/lsp/transport.rb +123 -0
- data/lib/canopus/lsp.rb +19 -0
- data/lib/canopus/markdown.rb +69 -0
- data/lib/canopus/match_data_compat.rb +17 -0
- data/lib/canopus/multi_buffer.rb +266 -0
- data/lib/canopus/pane.rb +61 -0
- data/lib/canopus/patch/composite.rb +12 -0
- data/lib/canopus/patch/reload.rb +18 -0
- data/lib/canopus/patch.rb +27 -0
- data/lib/canopus/performance_recorder.rb +207 -0
- data/lib/canopus/plugins/api.rb +50 -0
- data/lib/canopus/plugins/isolated_runtime.rb +167 -0
- data/lib/canopus/plugins/local_runtime.rb +25 -0
- data/lib/canopus/plugins/permission_denied.rb +7 -0
- data/lib/canopus/plugins/registry.rb +30 -0
- data/lib/canopus/plugins.rb +11 -0
- data/lib/canopus/project/ignore_matcher.rb +104 -0
- data/lib/canopus/project/search.rb +164 -0
- data/lib/canopus/project/search_worker/cancelled.rb +3 -0
- data/lib/canopus/project/search_worker/runner.rb +9 -0
- data/lib/canopus/project/search_worker.rb +108 -0
- data/lib/canopus/project/tree.rb +48 -0
- data/lib/canopus/project/watcher.rb +59 -0
- data/lib/canopus/project.rb +127 -0
- data/lib/canopus/regexp_compat.rb +30 -0
- data/lib/canopus/save_conflict.rb +5 -0
- data/lib/canopus/selection.rb +11 -0
- data/lib/canopus/settings.rb +118 -0
- data/lib/canopus/snippet/transform.rb +209 -0
- data/lib/canopus/snippet.rb +183 -0
- data/lib/canopus/tab_map.rb +30 -0
- data/lib/canopus/terminal/cell.rb +7 -0
- data/lib/canopus/terminal/grid.rb +321 -0
- data/lib/canopus/terminal/pty.rb +103 -0
- data/lib/canopus/terminal/scrollback.rb +38 -0
- data/lib/canopus/terminal/vt.rb +398 -0
- data/lib/canopus/terminal.rb +13 -0
- data/lib/canopus/theme.rb +43 -0
- data/lib/canopus/version.rb +5 -0
- data/lib/canopus/vim/commandable.rb +148 -0
- data/lib/canopus/vim/motionable.rb +321 -0
- data/lib/canopus/vim/operator_capable.rb +377 -0
- data/lib/canopus/vim/text_object_selectable.rb +141 -0
- data/lib/canopus/vim.rb +426 -0
- data/lib/canopus/workspace/edit/executable.rb +139 -0
- data/lib/canopus/workspace/edit/node.rb +8 -0
- data/lib/canopus/workspace/edit/plan.rb +170 -0
- data/lib/canopus/workspace/edit/resource_preparable.rb +98 -0
- data/lib/canopus/workspace/edit.rb +6 -0
- data/lib/canopus/workspace/file_change_aware.rb +40 -0
- data/lib/canopus/workspace/file_previewable.rb +31 -0
- data/lib/canopus/workspace/git_aware.rb +140 -0
- data/lib/canopus/workspace/language_aware.rb +413 -0
- data/lib/canopus/workspace/language_server_configurable.rb +181 -0
- data/lib/canopus/workspace/project_searchable.rb +208 -0
- data/lib/canopus/workspace/project_tree_editable.rb +82 -0
- data/lib/canopus/workspace/session_persistable.rb +153 -0
- data/lib/canopus/workspace/settings_aware.rb +91 -0
- data/lib/canopus/workspace/view/terminal_presentable.rb +185 -0
- data/lib/canopus/workspace/view.rb +643 -0
- data/lib/canopus/workspace.rb +525 -0
- data/lib/canopus/wrap_map.rb +108 -0
- data/lib/canopus.rb +24 -0
- data/sig/canopus.rbs +375 -0
- data/sig/controller.rbs +55 -0
- data/sig/display_stages.rbs +49 -0
- data/sig/git.rbs +140 -0
- data/sig/lsp.rbs +99 -0
- data/sig/markdown.rbs +11 -0
- data/sig/performance_recorder.rbs +23 -0
- data/sig/search_services.rbs +8 -0
- data/sig/services.rbs +81 -0
- data/sig/snippet.rbs +42 -0
- data/sig/terminal.rbs +125 -0
- data/sig/vim.rbs +27 -0
- data/sig/workspace_edits.rbs +11 -0
- data/sig/workspace_services.rbs +117 -0
- data/tools/certify_snippet_regex.rb +35 -0
- data/tools/check_dependencies.rb +80 -0
- data/tools/native_check.rb +70 -0
- data/tools/package.rb +113 -0
- data/tools/package_test.rb +32 -0
- metadata +314 -0
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "selection"
|
|
4
|
+
|
|
5
|
+
module Canopus
|
|
6
|
+
class Editor
|
|
7
|
+
PAIRS = {"(" => ")", "[" => "]", "{" => "}", '"' => '"', "'" => "'"}.freeze
|
|
8
|
+
attr_reader :buffer, :display_map, :selections, :scroll_x, :scroll_y
|
|
9
|
+
attr_accessor :tab_size, :use_tabs, :auto_pairs, :composition, :viewport_rows, :relative_line_numbers
|
|
10
|
+
|
|
11
|
+
def initialize(buffer = Buffer.new, wrap_width: nil, tab_size: 4)
|
|
12
|
+
@buffer, @next_selection = buffer, 0
|
|
13
|
+
@tab_size, @use_tabs, @auto_pairs = tab_size, false, true
|
|
14
|
+
@display_map = DisplayMap.new(buffer, wrap_width: wrap_width, tab_size: tab_size)
|
|
15
|
+
@scroll_x, @scroll_y, @viewport_rows = 0, 0, 30
|
|
16
|
+
select(0)
|
|
17
|
+
@subscription = buffer.on_edit do |patch|
|
|
18
|
+
@display_map.block_map.blocks.values.each { |block| @display_map.remove_block(block.id) if block.kind == :git_diff }
|
|
19
|
+
@selections = @selections.map { |s| Selection.new(s.id, patch.map_offset(s.anchor), patch.map_offset(s.head), s.goal) }.freeze
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
def dispose
|
|
23
|
+
@subscription.detach
|
|
24
|
+
@display_map.dispose
|
|
25
|
+
@language_document&.dispose
|
|
26
|
+
clear_snippet
|
|
27
|
+
end
|
|
28
|
+
def language_document = @language_document ||= Language::Document.new(@buffer)
|
|
29
|
+
def language=(definition)
|
|
30
|
+
@language_document&.dispose
|
|
31
|
+
@language_document = Language::Document.new(@buffer, definition: definition)
|
|
32
|
+
end
|
|
33
|
+
def primary = @selections.last
|
|
34
|
+
def select(first, last = first, add: false)
|
|
35
|
+
@buffer.rope.point_at(first)
|
|
36
|
+
@buffer.rope.point_at(last)
|
|
37
|
+
selection = Selection.new(@next_selection, first, last, nil)
|
|
38
|
+
@next_selection += 1
|
|
39
|
+
set_selections(add ? [*@selections, selection] : [selection])
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def set_selections(selections)
|
|
44
|
+
merged = []
|
|
45
|
+
selections.sort_by(&:start).each do |selection|
|
|
46
|
+
if merged.last && (selection.start < merged.last.end || (selection.start == merged.last.end && !snippet_active?))
|
|
47
|
+
previous = merged.pop
|
|
48
|
+
merged << Selection.new(previous.id, previous.start, [previous.end, selection.end].max, selection.goal)
|
|
49
|
+
else
|
|
50
|
+
merged << selection
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
@selections = merged.freeze
|
|
54
|
+
@buffer.selections = @selections
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def insert_text(text, auto_indent: true)
|
|
58
|
+
raise ArgumentError, "text must be UTF-8" unless text.is_a?(String) && text.valid_encoding?
|
|
59
|
+
texts = @selections.map do |selection|
|
|
60
|
+
if text == "\n" && auto_indent
|
|
61
|
+
indent = language_document.indent_for(selection.start, tab_size: @tab_size, use_tabs: @use_tabs)
|
|
62
|
+
if language_document.definition == Language::PLAIN
|
|
63
|
+
before = @buffer.line(@buffer.rope.point_at(selection.start).row)[0, @buffer.rope.point_at(selection.start).column]
|
|
64
|
+
indent += @use_tabs ? "\t" : " " * @tab_size if before.rstrip.end_with?("{", "[", "(")
|
|
65
|
+
end
|
|
66
|
+
@buffer.line_ending + indent
|
|
67
|
+
elsif @auto_pairs && PAIRS.key?(text)
|
|
68
|
+
text + @buffer.rope.byteslice(selection.range).to_s + PAIRS[text]
|
|
69
|
+
else
|
|
70
|
+
text
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
paired = @auto_pairs && PAIRS.key?(text)
|
|
74
|
+
replace_selections(texts, kind: :typing, group: true, cursor_back: paired ? PAIRS[text].bytesize : 0)
|
|
75
|
+
reveal_cursor
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def replace_selections(texts, kind: :edit, group: false, cursor_back: 0, before_selections: @selections)
|
|
79
|
+
texts = Array.new(@selections.length, texts) if texts.is_a?(String)
|
|
80
|
+
raise ArgumentError, "one text per selection required" unless texts.length == @selections.length
|
|
81
|
+
changes = @selections.zip(texts).map { |selection, text| [selection.range, text] }
|
|
82
|
+
@buffer.selections = before_selections
|
|
83
|
+
delta = 0
|
|
84
|
+
after = @selections.zip(texts).map do |selection, text|
|
|
85
|
+
ending = selection.start + delta + text.bytesize - cursor_back
|
|
86
|
+
delta += text.bytesize - (selection.end - selection.start)
|
|
87
|
+
Selection.new(selection.id, ending, ending, nil)
|
|
88
|
+
end
|
|
89
|
+
@buffer.edit(changes, kind: kind, selections: after, before_selections: before_selections, group: group)
|
|
90
|
+
set_selections(after)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def delete_backward
|
|
94
|
+
before = @selections
|
|
95
|
+
set_selections(@selections.map do |s|
|
|
96
|
+
s.empty? ? Selection.new(s.id, horizontal(s.head, -1), s.head, nil) : s
|
|
97
|
+
end)
|
|
98
|
+
replace_selections("", kind: :backspace, group: true, before_selections: before)
|
|
99
|
+
reveal_cursor
|
|
100
|
+
end
|
|
101
|
+
def delete_forward
|
|
102
|
+
before = @selections
|
|
103
|
+
set_selections(@selections.map do |s|
|
|
104
|
+
s.empty? ? Selection.new(s.id, s.head, horizontal(s.head, 1), nil) : s
|
|
105
|
+
end)
|
|
106
|
+
replace_selections("", kind: :delete, group: true, before_selections: before)
|
|
107
|
+
end
|
|
108
|
+
def undo
|
|
109
|
+
clear_snippet
|
|
110
|
+
return false unless @buffer.undo
|
|
111
|
+
set_selections(@buffer.selections)
|
|
112
|
+
reveal_cursor
|
|
113
|
+
true
|
|
114
|
+
end
|
|
115
|
+
def redo
|
|
116
|
+
clear_snippet
|
|
117
|
+
return false unless @buffer.redo
|
|
118
|
+
set_selections(@buffer.selections)
|
|
119
|
+
reveal_cursor
|
|
120
|
+
true
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def move(direction, extend: false, count: 1)
|
|
124
|
+
next_selections = @selections.map do |selection|
|
|
125
|
+
position, goal = selection.head, selection.goal
|
|
126
|
+
if !extend && !selection.empty? && [:left, :right].include?(direction)
|
|
127
|
+
position = direction == :left ? selection.start : selection.end
|
|
128
|
+
else
|
|
129
|
+
count.times do
|
|
130
|
+
point = @display_map.to_display(position)
|
|
131
|
+
row = @display_map.row(point.row)
|
|
132
|
+
case direction
|
|
133
|
+
when :left, :right
|
|
134
|
+
position = horizontal(position, direction == :left ? -1 : 1)
|
|
135
|
+
goal = nil
|
|
136
|
+
when :up, :down, :page_up, :page_down
|
|
137
|
+
goal ||= point.column
|
|
138
|
+
amount = [:page_up, :page_down].include?(direction) ? @viewport_rows : 1
|
|
139
|
+
amount *= -1 if [:up, :page_up].include?(direction)
|
|
140
|
+
target_row = (point.row + amount).clamp(0, @display_map.row_count - 1)
|
|
141
|
+
position = @display_map.to_buffer(DisplayPoint.new(target_row, goal))
|
|
142
|
+
when :line_start
|
|
143
|
+
position = @buffer.rope.respond_to?(:lazy?) ? @buffer.rope.line_start(point.row) : @display_map.to_buffer(DisplayPoint.new(point.row, 0)); goal = nil
|
|
144
|
+
when :line_end
|
|
145
|
+
position = @buffer.rope.respond_to?(:lazy?) ? @buffer.rope.line_end(point.row) : @display_map.to_buffer(DisplayPoint.new(point.row, row.text.length)); goal = nil
|
|
146
|
+
when :file_start then position = 0; goal = nil
|
|
147
|
+
when :file_end then position = @buffer.rope.bytesize; goal = nil
|
|
148
|
+
when :word_left, :word_right
|
|
149
|
+
position = word(position, direction == :word_left ? -1 : 1); goal = nil
|
|
150
|
+
when :paragraph_up, :paragraph_down
|
|
151
|
+
target = point.row
|
|
152
|
+
step = direction == :paragraph_up ? -1 : 1
|
|
153
|
+
loop do
|
|
154
|
+
target += step
|
|
155
|
+
break if target <= 0 || target >= @display_map.row_count - 1 || @display_map.row(target).text.strip.empty?
|
|
156
|
+
end
|
|
157
|
+
position = @display_map.to_buffer(DisplayPoint.new(target.clamp(0, @display_map.row_count - 1), 0))
|
|
158
|
+
else raise ArgumentError, "unknown movement #{direction}"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
Selection.new(selection.id, extend ? selection.anchor : position, position, goal)
|
|
163
|
+
end
|
|
164
|
+
set_selections(next_selections)
|
|
165
|
+
reveal_cursor
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def select_all = select(0, @buffer.rope.bytesize)
|
|
169
|
+
def select_next_occurrence(all: false)
|
|
170
|
+
needle = @buffer.rope.byteslice(primary.range).to_s
|
|
171
|
+
if needle.empty?
|
|
172
|
+
left, right = word(primary.head, -1), word(primary.head, 1)
|
|
173
|
+
select(left, right)
|
|
174
|
+
needle = @buffer.rope.byteslice(primary.range).to_s
|
|
175
|
+
end
|
|
176
|
+
return if needle.empty?
|
|
177
|
+
text = @buffer.text.b
|
|
178
|
+
cursor, matches = 0, []
|
|
179
|
+
while (found = text.index(needle.b, cursor))
|
|
180
|
+
matches << (found...(found + needle.bytesize))
|
|
181
|
+
cursor = found + needle.bytesize
|
|
182
|
+
end
|
|
183
|
+
matches.reject! { |range| @selections.any? { |s| s.start == range.begin && s.end == range.end } }
|
|
184
|
+
matches = [(matches.find { |range| range.begin >= primary.end } || matches.first)].compact unless all
|
|
185
|
+
matches.each { |range| select(range.begin, range.end, add: true) }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def rectangle(first, last)
|
|
189
|
+
selections = (first.row..last.row).map do |row|
|
|
190
|
+
anchor = @display_map.to_buffer(DisplayPoint.new(row, first.column))
|
|
191
|
+
head = @display_map.to_buffer(DisplayPoint.new(row, last.column))
|
|
192
|
+
@next_selection += 1
|
|
193
|
+
Selection.new(@next_selection, anchor, head, nil)
|
|
194
|
+
end
|
|
195
|
+
set_selections(selections)
|
|
196
|
+
end
|
|
197
|
+
def indent(outdent: false)
|
|
198
|
+
change_lines(:indent) do |line|
|
|
199
|
+
outdent ? line.sub(/\A(?:\t| {1,#{@tab_size}})/, "") : (@use_tabs ? "\t" : " " * @tab_size) + line
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
def toggle_comment(prefix: "#", suffix: nil)
|
|
203
|
+
return if prefix.empty?
|
|
204
|
+
suffix ||= case prefix
|
|
205
|
+
when "/*" then "*/"
|
|
206
|
+
when "<!--" then "-->"
|
|
207
|
+
end
|
|
208
|
+
rows = selected_rows
|
|
209
|
+
uncomment = rows.all? do |row|
|
|
210
|
+
line = @buffer.line(row).strip
|
|
211
|
+
line.start_with?(prefix) && (!suffix || line.end_with?(suffix))
|
|
212
|
+
end
|
|
213
|
+
if suffix && !uncomment && rows.any? { |row| @buffer.line(row).include?(suffix) }
|
|
214
|
+
raise Error, "cannot wrap a line containing the closing comment delimiter #{suffix}"
|
|
215
|
+
end
|
|
216
|
+
change_lines(:comment) do |line|
|
|
217
|
+
if uncomment
|
|
218
|
+
value = line.sub(/\A([ \t]*)#{Regexp.escape(prefix)} ?/, '\1')
|
|
219
|
+
suffix ? value.sub(/ ?#{Regexp.escape(suffix)}([ \t]*)\z/, '\1') : value
|
|
220
|
+
elsif suffix
|
|
221
|
+
line.sub(/\A([ \t]*)(.*?)([ \t]*)\z/) { "#{Regexp.last_match(1)}#{prefix} #{Regexp.last_match(2)} #{suffix}#{Regexp.last_match(3)}" }
|
|
222
|
+
else
|
|
223
|
+
line.sub(/\A([ \t]*)/, "\\1#{prefix} ")
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
def duplicate_lines
|
|
228
|
+
rows = selected_rows
|
|
229
|
+
changes = rows.map do |row|
|
|
230
|
+
ending = row + 1 < @buffer.line_count ? @buffer.rope.line_start(row + 1) : @buffer.rope.bytesize
|
|
231
|
+
value = @buffer.line(row) + @buffer.line_ending
|
|
232
|
+
value = @buffer.line_ending + @buffer.line(row) if row == @buffer.line_count - 1
|
|
233
|
+
[ending...ending, value]
|
|
234
|
+
end
|
|
235
|
+
edit(changes, kind: :duplicate)
|
|
236
|
+
end
|
|
237
|
+
def move_lines(direction)
|
|
238
|
+
raise ArgumentError, "line direction must be up or down" unless %i[up down].include?(direction)
|
|
239
|
+
first, last = selected_rows.minmax
|
|
240
|
+
step = direction == :up ? -1 : 1
|
|
241
|
+
return if step.negative? ? first.zero? : last + 1 >= @buffer.line_count
|
|
242
|
+
rope = @buffer.rope
|
|
243
|
+
from, to = step.negative? ? [first - 1, last] : [first, last + 1]
|
|
244
|
+
rows, endings = [], []
|
|
245
|
+
(from..to).each do |row|
|
|
246
|
+
text = @buffer.line(row)
|
|
247
|
+
ending = row + 1 < rope.line_count ? rope.line_start(row + 1) : rope.bytesize
|
|
248
|
+
body_end = rope.line_start(row) + text.bytesize
|
|
249
|
+
rows << text
|
|
250
|
+
endings << rope.byteslice(body_end, ending - body_end).to_s
|
|
251
|
+
end
|
|
252
|
+
# Move contents while preserving the separators at each row, including
|
|
253
|
+
# mixed CRLF/LF/Unicode endings and an unterminated final row.
|
|
254
|
+
rows.rotate!(-step)
|
|
255
|
+
start = rope.line_start(from)
|
|
256
|
+
previous_end = from.positive? ? rope.line_start(from - 1) + @buffer.line(from - 1).bytesize : 0
|
|
257
|
+
trailing_cr = from.positive? && rope.byteslice(previous_end, start - previous_end).to_s == "\r"
|
|
258
|
+
replacement = +""
|
|
259
|
+
rows.each_with_index do |text, index|
|
|
260
|
+
ending = endings[index]
|
|
261
|
+
# An empty moved row can put CR beside LF. Keep two logical breaks by
|
|
262
|
+
# expanding that LF to CRLF instead of silently merging the rows.
|
|
263
|
+
replacement << text
|
|
264
|
+
replacement << "\r" if text.empty? && trailing_cr && ending == "\n"
|
|
265
|
+
replacement << ending
|
|
266
|
+
trailing_cr = ending == "\r"
|
|
267
|
+
end
|
|
268
|
+
finish = to + 1 < rope.line_count ? rope.line_start(to + 1) : rope.bytesize
|
|
269
|
+
changes = [[start...finish, replacement]]
|
|
270
|
+
after = rope.apply_edits(changes)
|
|
271
|
+
move = lambda do |offset|
|
|
272
|
+
row = rope.point_at(offset).row
|
|
273
|
+
target = row + step
|
|
274
|
+
next after.bytesize if target >= after.line_count
|
|
275
|
+
column = row > last ? 0 : [offset - rope.line_start(row), after.line(target).bytesize].min
|
|
276
|
+
after.line_start(target) + column
|
|
277
|
+
end
|
|
278
|
+
selections = @selections.map { |selection| Selection.new(selection.id, move.call(selection.anchor), move.call(selection.head), selection.goal) }
|
|
279
|
+
@buffer.edit(changes, kind: :move_lines, selections: selections, before_selections: @selections)
|
|
280
|
+
set_selections(selections)
|
|
281
|
+
end
|
|
282
|
+
def self.search_pattern(pattern, case_sensitive: true, whole_word: false)
|
|
283
|
+
source = pattern.is_a?(Regexp) ? pattern.source : Regexp.escape(pattern)
|
|
284
|
+
source = "\\b(?:#{source})\\b" if whole_word
|
|
285
|
+
flags = (pattern.is_a?(Regexp) ? pattern.options : 0) | (case_sensitive ? 0 : Regexp::IGNORECASE)
|
|
286
|
+
Regexp.new(source, flags, timeout: pattern.is_a?(Regexp) && pattern.timeout || 0.25)
|
|
287
|
+
end
|
|
288
|
+
def search(pattern, case_sensitive: true, whole_word: false, range: nil)
|
|
289
|
+
regex = self.class.search_pattern(pattern, case_sensitive: case_sensitive, whole_word: whole_word)
|
|
290
|
+
base = range ? range.begin : 0
|
|
291
|
+
source = range ? @buffer.rope.byteslice(range).to_s : @buffer.text
|
|
292
|
+
matches = []
|
|
293
|
+
Canopus.with_regexp_timeout(regex) do
|
|
294
|
+
source.to_enum(:scan, regex).each do
|
|
295
|
+
found = Regexp.last_match
|
|
296
|
+
matches << ((base + found.bytebegin(0))...(base + found.byteend(0)))
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
matches
|
|
300
|
+
end
|
|
301
|
+
def replacement_edits(pattern, replacement, case_sensitive: true, whole_word: false, range: nil)
|
|
302
|
+
regex = self.class.search_pattern(pattern, case_sensitive: case_sensitive, whole_word: whole_word)
|
|
303
|
+
source = range ? @buffer.rope.byteslice(range).to_s : @buffer.text
|
|
304
|
+
base = range ? range.begin : 0
|
|
305
|
+
Canopus.with_regexp_timeout(regex) do
|
|
306
|
+
source.to_enum(:scan, regex).map do
|
|
307
|
+
found = Regexp.last_match
|
|
308
|
+
value = replacement.gsub(/\\(?:([0-9&`'+\\])|k<([^>]*)(>)?)/) do
|
|
309
|
+
escape, name, closing = Regexp.last_match.captures
|
|
310
|
+
if name
|
|
311
|
+
raise RuntimeError, "invalid group name reference format" unless closing
|
|
312
|
+
found[name].to_s
|
|
313
|
+
else
|
|
314
|
+
case escape
|
|
315
|
+
when "0", "&" then found[0]
|
|
316
|
+
when "`" then found.pre_match
|
|
317
|
+
when "'" then found.post_match
|
|
318
|
+
when "+" then found.captures.reverse.find(&:itself).to_s
|
|
319
|
+
when "\\" then "\\"
|
|
320
|
+
else found.names.empty? ? found[escape.to_i].to_s : ""
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
[(base + found.bytebegin(0))...(base + found.byteend(0)), value]
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
def replace_all(pattern, replacement, **options)
|
|
329
|
+
changes = replacement_edits(pattern, replacement, **options)
|
|
330
|
+
edit(changes, kind: :replace)
|
|
331
|
+
changes.length
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Apply externally computed edits with the same selection history as typing.
|
|
335
|
+
def edit(changes, kind: :edit)
|
|
336
|
+
return if changes.empty?
|
|
337
|
+
after = @buffer.rope.apply_edits(changes)
|
|
338
|
+
patch = Patch.new(@buffer.rope, after, changes)
|
|
339
|
+
selections = @selections.map { |s| Selection.new(s.id, patch.map_offset(s.anchor, bias: :left), patch.map_offset(s.head), nil) }
|
|
340
|
+
@buffer.edit(changes, kind: kind, selections: selections, before_selections: @selections)
|
|
341
|
+
set_selections(selections)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def scroll(dx: 0, dy: 0)
|
|
345
|
+
@scroll_x = [@scroll_x + dx, 0].max
|
|
346
|
+
@scroll_y = (@scroll_y + dy).clamp(0, [@display_map.row_count - @viewport_rows, 0].max)
|
|
347
|
+
end
|
|
348
|
+
def reveal_cursor
|
|
349
|
+
row = @display_map.to_display(primary.head).row
|
|
350
|
+
@scroll_y = row if row < @scroll_y
|
|
351
|
+
@scroll_y = row - @viewport_rows + 1 if row >= @scroll_y + @viewport_rows
|
|
352
|
+
@scroll_y = [@scroll_y, 0].max
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
private
|
|
356
|
+
def horizontal(offset, direction)
|
|
357
|
+
if @buffer.rope.respond_to?(:lazy?)
|
|
358
|
+
rope = @buffer.rope
|
|
359
|
+
point = rope.point_at(offset)
|
|
360
|
+
start, ending = rope.line_start(point.row), rope.line_end(point.row)
|
|
361
|
+
if direction.negative?
|
|
362
|
+
return point.row.zero? ? 0 : rope.line_end(point.row - 1) if offset == start
|
|
363
|
+
size = 8192
|
|
364
|
+
loop do
|
|
365
|
+
text, local = rope.line_window(point.row, from: [offset - start - size, 0].max, max_bytes: size + 4)
|
|
366
|
+
clusters = text.byteslice(0, offset - start - local).grapheme_clusters
|
|
367
|
+
return offset - (clusters.last&.bytesize || 0) if local.zero? || clusters.length > 1
|
|
368
|
+
size *= 2
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
return point.row + 1 < rope.line_count ? rope.line_start(point.row + 1) : ending if offset >= ending
|
|
372
|
+
size = 8192
|
|
373
|
+
loop do
|
|
374
|
+
text, = rope.line_window(point.row, from: offset - start, max_bytes: size)
|
|
375
|
+
following = text.each_grapheme_cluster.first
|
|
376
|
+
return offset + following.bytesize if following.bytesize < text.bytesize || offset + text.bytesize == ending
|
|
377
|
+
size *= 2
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
point = @display_map.to_display(offset)
|
|
381
|
+
line = @display_map.row(point.row).text
|
|
382
|
+
if direction.negative?
|
|
383
|
+
return @display_map.to_buffer(DisplayPoint.new(point.row - 1, @display_map.row(point.row - 1).text.length)) if point.column.zero? && point.row.positive?
|
|
384
|
+
previous = line[0...point.column].grapheme_clusters.last
|
|
385
|
+
column = point.column - (previous&.length || 0)
|
|
386
|
+
else
|
|
387
|
+
return @display_map.to_buffer(DisplayPoint.new(point.row + 1, 0)) if point.column >= line.length && point.row + 1 < @display_map.row_count
|
|
388
|
+
following = line[point.column..].grapheme_clusters.first
|
|
389
|
+
column = point.column + (following&.length || 0)
|
|
390
|
+
end
|
|
391
|
+
@display_map.to_buffer(DisplayPoint.new(point.row, column), bias: direction.negative? ? :left : :right)
|
|
392
|
+
end
|
|
393
|
+
def word(offset, direction)
|
|
394
|
+
return lazy_word(offset, direction) if @buffer.rope.respond_to?(:lazy?)
|
|
395
|
+
point = @buffer.rope.point_at(offset)
|
|
396
|
+
line = @buffer.line(point.row)
|
|
397
|
+
if direction.negative?
|
|
398
|
+
prefix = line[0...point.column]
|
|
399
|
+
match = prefix[/[\p{Alnum}_]+[^\p{Alnum}_]*\z|[^\p{Alnum}_]+\z/]
|
|
400
|
+
match ? offset - match.bytesize : horizontal(offset, -1)
|
|
401
|
+
else
|
|
402
|
+
suffix = line[point.column..]
|
|
403
|
+
match = suffix[/\A(?:[\p{Alnum}_]+[^\p{Alnum}_]*|[^\p{Alnum}_]+)/]
|
|
404
|
+
match ? offset + match.bytesize : horizontal(offset, 1)
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
def lazy_word(offset, direction)
|
|
408
|
+
rope = @buffer.rope
|
|
409
|
+
point = rope.point_at(offset)
|
|
410
|
+
start, ending = rope.line_start(point.row), rope.line_end(point.row)
|
|
411
|
+
return horizontal(offset, direction) if direction.negative? ? offset == start : offset == ending
|
|
412
|
+
phase = nil
|
|
413
|
+
loop do
|
|
414
|
+
text, local = rope.line_window(point.row, from: direction.negative? ? [offset - start - 8192, 0].max : offset - start)
|
|
415
|
+
chars = direction.negative? ? text.byteslice(0, offset - start - local).each_char.to_a.reverse_each : text.each_char
|
|
416
|
+
chars.each do |char|
|
|
417
|
+
word = /[\p{Alnum}_]/.match?(char)
|
|
418
|
+
current = direction.negative? ? !word : word
|
|
419
|
+
return offset if phase == false && current
|
|
420
|
+
phase = current if phase.nil? || !current
|
|
421
|
+
offset += direction * char.bytesize
|
|
422
|
+
end
|
|
423
|
+
return offset if offset == start || offset == ending
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
def selected_rows
|
|
427
|
+
@selections.flat_map do |s|
|
|
428
|
+
first, last = @buffer.rope.point_at(s.start).row, @buffer.rope.point_at(s.end).row
|
|
429
|
+
last -= 1 if !s.empty? && last > first && s.end == @buffer.rope.line_start(last)
|
|
430
|
+
(first..last).to_a
|
|
431
|
+
end.uniq.sort
|
|
432
|
+
end
|
|
433
|
+
def change_lines(kind)
|
|
434
|
+
changes = selected_rows.map do |row|
|
|
435
|
+
start = @buffer.rope.line_start(row)
|
|
436
|
+
value = @buffer.line(row)
|
|
437
|
+
[start...(start + value.bytesize), yield(value)]
|
|
438
|
+
end
|
|
439
|
+
edit(changes, kind: kind)
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
require_relative "editor/snippet_expandable"
|
|
445
|
+
Canopus::Editor.include Canopus::Editor::SnippetExpandable
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canopus
|
|
4
|
+
class FoldMap
|
|
5
|
+
attr_reader :ranges
|
|
6
|
+
def initialize = @ranges = []
|
|
7
|
+
def fold(range)
|
|
8
|
+
ending = range.end + (range.exclude_end? ? 0 : 1)
|
|
9
|
+
raise ArgumentError, "invalid fold" unless range.begin >= 0 && ending > range.begin
|
|
10
|
+
@ranges << (range.begin...ending)
|
|
11
|
+
merged = []
|
|
12
|
+
@ranges.sort_by(&:begin).each do |entry|
|
|
13
|
+
if merged.last && entry.begin < merged.last.end
|
|
14
|
+
previous = merged.pop
|
|
15
|
+
merged << (previous.begin...[previous.end, entry.end].max)
|
|
16
|
+
else
|
|
17
|
+
merged << entry
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
@ranges = merged
|
|
21
|
+
end
|
|
22
|
+
def unfold(offset) = @ranges.reject! { |range| range.cover?(offset) }
|
|
23
|
+
def apply(patch)
|
|
24
|
+
@ranges = @ranges.filter_map do |range|
|
|
25
|
+
first, last = patch.map_offset(range.begin, bias: :left), patch.map_offset(range.end, bias: :right)
|
|
26
|
+
first...last if first < last
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
def transform(rope, row, max_bytes: nil, from: 0, checkpoint: nil)
|
|
30
|
+
start = rope.line_start(row)
|
|
31
|
+
return nil if @ranges.any? { |range| range.begin < start && range.end >= start }
|
|
32
|
+
finish = line_end(rope, row)
|
|
33
|
+
segments, cursor = [], start
|
|
34
|
+
@ranges.each do |range|
|
|
35
|
+
next if range.end <= cursor || range.begin > finish
|
|
36
|
+
segments << [cursor, range.begin, nil]
|
|
37
|
+
segments << [range.begin, range.end, "…"]
|
|
38
|
+
cursor = range.end
|
|
39
|
+
end_row = rope.point_at(cursor).row
|
|
40
|
+
finish = line_end(rope, end_row)
|
|
41
|
+
end
|
|
42
|
+
segments << [cursor, [finish, cursor].max, nil]
|
|
43
|
+
text, offsets = +"", []
|
|
44
|
+
requested = start + from.clamp(0, finish - start)
|
|
45
|
+
segments.each do |first, last, marker|
|
|
46
|
+
next if last < requested || (last == requested && first < last)
|
|
47
|
+
break if max_bytes && text.bytesize >= max_bytes
|
|
48
|
+
if marker
|
|
49
|
+
offsets << first - start if offsets.empty?
|
|
50
|
+
text << marker
|
|
51
|
+
offsets << last - start
|
|
52
|
+
else
|
|
53
|
+
first = boundary(rope, [first, requested].max, -1)
|
|
54
|
+
last = boundary(rope, [last, first + max_bytes - text.bytesize].min, -1) if max_bytes
|
|
55
|
+
offsets << first - start if offsets.empty?
|
|
56
|
+
value = rope.byteslice(first, [last - first, 0].max).to_s
|
|
57
|
+
append(text, offsets, value, first - start, checkpoint)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
offsets << finish - start if offsets.empty?
|
|
61
|
+
[text, offsets]
|
|
62
|
+
end
|
|
63
|
+
private
|
|
64
|
+
def line_end(rope, row)
|
|
65
|
+
return rope.bytesize if row + 1 == rope.line_count
|
|
66
|
+
ending = rope.line_start(row + 1)
|
|
67
|
+
first = boundary(rope, [ending - 4, rope.line_start(row)].max, 1)
|
|
68
|
+
tail = rope.byteslice(first, ending - first).to_s
|
|
69
|
+
ending - tail[/(?:\r\n|[\r\n\u2028\u2029])\z/].to_s.bytesize
|
|
70
|
+
end
|
|
71
|
+
def boundary(rope, offset, step)
|
|
72
|
+
rope.point_at(offset)
|
|
73
|
+
offset
|
|
74
|
+
rescue RangeError
|
|
75
|
+
offset += step
|
|
76
|
+
retry
|
|
77
|
+
end
|
|
78
|
+
def append(text, offsets, value, local, checkpoint)
|
|
79
|
+
text << value
|
|
80
|
+
value.each_char.with_index do |char, index|
|
|
81
|
+
checkpoint&.call if (index & 1023).zero?
|
|
82
|
+
local += char.bytesize
|
|
83
|
+
offsets << local
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canopus
|
|
4
|
+
module Git
|
|
5
|
+
class Blame
|
|
6
|
+
Line = Struct.new(:line, :text, :commit, :author, :original_line, :path, keyword_init: true)
|
|
7
|
+
|
|
8
|
+
def initialize(repository)
|
|
9
|
+
@repository = repository
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(path, reference: "HEAD")
|
|
13
|
+
revision = @repository.commit(reference)
|
|
14
|
+
return [] unless revision
|
|
15
|
+
current = @repository.blob(path, reference: revision.oid)
|
|
16
|
+
return [] unless current
|
|
17
|
+
result = Array.new(current.lines.length)
|
|
18
|
+
pending = [[revision, path, current, result.each_index.to_h { |index| [index, index] }]]
|
|
19
|
+
until pending.empty?
|
|
20
|
+
commit, current_path, contents, unresolved = pending.pop
|
|
21
|
+
commit.parents.each do |parent_oid|
|
|
22
|
+
break if unresolved.empty?
|
|
23
|
+
parent = @repository.commit(parent_oid)
|
|
24
|
+
parent_tree = @repository.tree(parent_oid)
|
|
25
|
+
parent_path = current_path
|
|
26
|
+
unless parent_tree.key?(current_path)
|
|
27
|
+
current_oid = @repository.tree(commit.oid)[current_path]&.oid
|
|
28
|
+
parent_path = parent_tree.values.find { |entry| entry.oid == current_oid }&.path
|
|
29
|
+
end
|
|
30
|
+
next unless parent_path
|
|
31
|
+
previous = @repository.blob(parent_path, reference: parent_oid)
|
|
32
|
+
next unless previous
|
|
33
|
+
inherited = {}
|
|
34
|
+
Diff.edits(previous, contents).each do |edit|
|
|
35
|
+
next unless edit.kind == :equal && unresolved.key?(edit.new_line - 1)
|
|
36
|
+
inherited[edit.old_line - 1] = unresolved.delete(edit.new_line - 1)
|
|
37
|
+
end
|
|
38
|
+
pending << [parent, parent_path, previous, inherited] unless inherited.empty?
|
|
39
|
+
end
|
|
40
|
+
lines = contents.lines
|
|
41
|
+
unresolved.each do |original, target|
|
|
42
|
+
result[target] = Line.new(line: target + 1, text: lines[original].force_encoding(Encoding::UTF_8),
|
|
43
|
+
commit: commit.oid, author: commit.author, original_line: original + 1, path: current_path)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
result
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|