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.
Files changed (146) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +148 -0
  5. data/docs/adr/001-runtime-gem-components.md +24 -0
  6. data/docs/adr/002-bounded-diff-engine.md +22 -0
  7. data/docs/adr/003-background-language-analysis.md +22 -0
  8. data/docs/adr/004-persistent-display-map.md +23 -0
  9. data/docs/adr/005-windows-runtime.md +27 -0
  10. data/docs/adr/README.md +36 -0
  11. data/docs/distribution.md +27 -0
  12. data/docs/lsp.md +38 -0
  13. data/docs/performance.md +40 -0
  14. data/docs/snippets.md +32 -0
  15. data/docs/vim.md +24 -0
  16. data/docs/workspace_edits.md +32 -0
  17. data/examples/native_smoke.rb +25 -0
  18. data/examples/plugins/word_count.rb +11 -0
  19. data/exe/canopus +19 -0
  20. data/lib/canopus/block_map.rb +19 -0
  21. data/lib/canopus/buffer.rb +293 -0
  22. data/lib/canopus/cli.rb +171 -0
  23. data/lib/canopus/controller.rb +507 -0
  24. data/lib/canopus/data_compat.rb +25 -0
  25. data/lib/canopus/display_map/line_builder.rb +54 -0
  26. data/lib/canopus/display_map/line_set.rb +8 -0
  27. data/lib/canopus/display_map/pending_line_set.rb +9 -0
  28. data/lib/canopus/display_map/summary.rb +23 -0
  29. data/lib/canopus/display_map/worker.rb +89 -0
  30. data/lib/canopus/display_map.rb +350 -0
  31. data/lib/canopus/display_point.rb +5 -0
  32. data/lib/canopus/editor/snippet_expandable.rb +281 -0
  33. data/lib/canopus/editor.rb +445 -0
  34. data/lib/canopus/error.rb +5 -0
  35. data/lib/canopus/fold_map.rb +87 -0
  36. data/lib/canopus/git/blame.rb +50 -0
  37. data/lib/canopus/git/commit.rb +7 -0
  38. data/lib/canopus/git/corrupt_object.rb +7 -0
  39. data/lib/canopus/git/diff.rb +131 -0
  40. data/lib/canopus/git/index.rb +94 -0
  41. data/lib/canopus/git/object_database.rb +45 -0
  42. data/lib/canopus/git/pack.rb +186 -0
  43. data/lib/canopus/git/repository.rb +313 -0
  44. data/lib/canopus/git/status.rb +74 -0
  45. data/lib/canopus/git/tree_entry.rb +7 -0
  46. data/lib/canopus/git.rb +15 -0
  47. data/lib/canopus/icon_theme.rb +43 -0
  48. data/lib/canopus/language/background_analysis/job.rb +12 -0
  49. data/lib/canopus/language/background_analysis/scheduler.rb +70 -0
  50. data/lib/canopus/language/background_analysis.rb +280 -0
  51. data/lib/canopus/language/definition.rb +7 -0
  52. data/lib/canopus/language/document.rb +143 -0
  53. data/lib/canopus/language/symbol.rb +7 -0
  54. data/lib/canopus/language/syntax_worker.rb +91 -0
  55. data/lib/canopus/language.rb +42 -0
  56. data/lib/canopus/lazy_rope.rb +218 -0
  57. data/lib/canopus/lsp/client.rb +299 -0
  58. data/lib/canopus/lsp/error.rb +7 -0
  59. data/lib/canopus/lsp/future/subscription.rb +9 -0
  60. data/lib/canopus/lsp/future.rb +87 -0
  61. data/lib/canopus/lsp/protocol.rb +83 -0
  62. data/lib/canopus/lsp/server_error.rb +13 -0
  63. data/lib/canopus/lsp/timeout.rb +7 -0
  64. data/lib/canopus/lsp/transport.rb +123 -0
  65. data/lib/canopus/lsp.rb +19 -0
  66. data/lib/canopus/markdown.rb +69 -0
  67. data/lib/canopus/match_data_compat.rb +17 -0
  68. data/lib/canopus/multi_buffer.rb +266 -0
  69. data/lib/canopus/pane.rb +61 -0
  70. data/lib/canopus/patch/composite.rb +12 -0
  71. data/lib/canopus/patch/reload.rb +18 -0
  72. data/lib/canopus/patch.rb +27 -0
  73. data/lib/canopus/performance_recorder.rb +207 -0
  74. data/lib/canopus/plugins/api.rb +50 -0
  75. data/lib/canopus/plugins/isolated_runtime.rb +167 -0
  76. data/lib/canopus/plugins/local_runtime.rb +25 -0
  77. data/lib/canopus/plugins/permission_denied.rb +7 -0
  78. data/lib/canopus/plugins/registry.rb +30 -0
  79. data/lib/canopus/plugins.rb +11 -0
  80. data/lib/canopus/project/ignore_matcher.rb +104 -0
  81. data/lib/canopus/project/search.rb +164 -0
  82. data/lib/canopus/project/search_worker/cancelled.rb +3 -0
  83. data/lib/canopus/project/search_worker/runner.rb +9 -0
  84. data/lib/canopus/project/search_worker.rb +108 -0
  85. data/lib/canopus/project/tree.rb +48 -0
  86. data/lib/canopus/project/watcher.rb +59 -0
  87. data/lib/canopus/project.rb +127 -0
  88. data/lib/canopus/regexp_compat.rb +30 -0
  89. data/lib/canopus/save_conflict.rb +5 -0
  90. data/lib/canopus/selection.rb +11 -0
  91. data/lib/canopus/settings.rb +118 -0
  92. data/lib/canopus/snippet/transform.rb +209 -0
  93. data/lib/canopus/snippet.rb +183 -0
  94. data/lib/canopus/tab_map.rb +30 -0
  95. data/lib/canopus/terminal/cell.rb +7 -0
  96. data/lib/canopus/terminal/grid.rb +321 -0
  97. data/lib/canopus/terminal/pty.rb +103 -0
  98. data/lib/canopus/terminal/scrollback.rb +38 -0
  99. data/lib/canopus/terminal/vt.rb +398 -0
  100. data/lib/canopus/terminal.rb +13 -0
  101. data/lib/canopus/theme.rb +43 -0
  102. data/lib/canopus/version.rb +5 -0
  103. data/lib/canopus/vim/commandable.rb +148 -0
  104. data/lib/canopus/vim/motionable.rb +321 -0
  105. data/lib/canopus/vim/operator_capable.rb +377 -0
  106. data/lib/canopus/vim/text_object_selectable.rb +141 -0
  107. data/lib/canopus/vim.rb +426 -0
  108. data/lib/canopus/workspace/edit/executable.rb +139 -0
  109. data/lib/canopus/workspace/edit/node.rb +8 -0
  110. data/lib/canopus/workspace/edit/plan.rb +170 -0
  111. data/lib/canopus/workspace/edit/resource_preparable.rb +98 -0
  112. data/lib/canopus/workspace/edit.rb +6 -0
  113. data/lib/canopus/workspace/file_change_aware.rb +40 -0
  114. data/lib/canopus/workspace/file_previewable.rb +31 -0
  115. data/lib/canopus/workspace/git_aware.rb +140 -0
  116. data/lib/canopus/workspace/language_aware.rb +413 -0
  117. data/lib/canopus/workspace/language_server_configurable.rb +181 -0
  118. data/lib/canopus/workspace/project_searchable.rb +208 -0
  119. data/lib/canopus/workspace/project_tree_editable.rb +82 -0
  120. data/lib/canopus/workspace/session_persistable.rb +153 -0
  121. data/lib/canopus/workspace/settings_aware.rb +91 -0
  122. data/lib/canopus/workspace/view/terminal_presentable.rb +185 -0
  123. data/lib/canopus/workspace/view.rb +643 -0
  124. data/lib/canopus/workspace.rb +525 -0
  125. data/lib/canopus/wrap_map.rb +108 -0
  126. data/lib/canopus.rb +24 -0
  127. data/sig/canopus.rbs +375 -0
  128. data/sig/controller.rbs +55 -0
  129. data/sig/display_stages.rbs +49 -0
  130. data/sig/git.rbs +140 -0
  131. data/sig/lsp.rbs +99 -0
  132. data/sig/markdown.rbs +11 -0
  133. data/sig/performance_recorder.rbs +23 -0
  134. data/sig/search_services.rbs +8 -0
  135. data/sig/services.rbs +81 -0
  136. data/sig/snippet.rbs +42 -0
  137. data/sig/terminal.rbs +125 -0
  138. data/sig/vim.rbs +27 -0
  139. data/sig/workspace_edits.rbs +11 -0
  140. data/sig/workspace_services.rbs +117 -0
  141. data/tools/certify_snippet_regex.rb +35 -0
  142. data/tools/check_dependencies.rb +80 -0
  143. data/tools/native_check.rb +70 -0
  144. data/tools/package.rb +113 -0
  145. data/tools/package_test.rb +32 -0
  146. 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canopus
4
+ class Error < StandardError; end
5
+ end
@@ -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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canopus
4
+ module Git
5
+ Commit = Struct.new(:oid, :tree, :parents, :author, :committer, :message, keyword_init: true)
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canopus
4
+ module Git
5
+ class CorruptObject < StandardError; end
6
+ end
7
+ end