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,426 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canopus
4
+ class Vim
5
+ OPERATORS = %w[d c y > < = gu gU g~].freeze
6
+ VISUAL_MODES = %i[visual visual_line visual_block].freeze
7
+ attr_reader :mode, :registers, :marks, :macros, :command_line, :status
8
+ attr_accessor :on_command
9
+
10
+ def initialize(editor)
11
+ @editor, @mode = editor, :normal
12
+ @registers, @marks, @macros = {}, {}, {}
13
+ @count, @command, @last_change = +"", [], []
14
+ @register, @status, @play_depth = '"', "NORMAL", 0
15
+ end
16
+
17
+ def cursor_position = visual? ? @visual_head : @editor.primary.head
18
+
19
+ # A pane losing focus must not keep a shared buffer's undo transaction open.
20
+ # Return to Normal mode, retaining marks, registers and the last change.
21
+ def deactivate
22
+ %i[insert replace].include?(@mode) ? insert_key("esc") : escape
23
+ @command_line = nil
24
+ @status = "NORMAL"
25
+ self
26
+ end
27
+
28
+ def dispose
29
+ end_group
30
+ (@marks.values + [@block_insert_start]).compact.uniq.each { |anchor| @editor.buffer.release_anchor(anchor) }
31
+ @marks.clear
32
+ @block_insert_start = nil
33
+ end
34
+
35
+ def feed(key)
36
+ @message = nil
37
+ key = "esc" if key == "\e"
38
+ key = "enter" if ["\r", "\n"].include?(key)
39
+ @macros[@recording] << key if @recording && !(key == "q" && @mode == :normal && !@prefix)
40
+ return command_key(key) if @command_line
41
+ return insert_key(key) if %i[insert replace].include?(@mode)
42
+ @visual_keys << key if visual? && @visual_keys
43
+ @command << key
44
+ return escape if %w[esc ctrl-c].include?(key)
45
+ return prefix_key(key) if @prefix
46
+ if key.match?(/\A\d\z/) && (key != "0" || !@count.empty?)
47
+ @count << key
48
+ return
49
+ end
50
+ explicit = !@count.empty?
51
+ count = explicit ? @count.to_i.clamp(1, 10_000) : 1
52
+ @count.clear
53
+ return operate_key(key, count, explicit) if @operator
54
+ if visual? && (%w[d c y > < = x s D C Y].include?(key))
55
+ return operate_visual({"x" => "d", "s" => "c", "D" => "d", "C" => "c", "Y" => "y"}.fetch(key, key), count: count)
56
+ end
57
+ if visual? && %w[i a].include?(key)
58
+ @prefix, @prefix_count = key, count
59
+ return
60
+ end
61
+ case key
62
+ when "i", "a", "I", "A", "o", "O", "R"
63
+ enter_insert(key, count)
64
+ when "v", "V", "ctrl-v"
65
+ requested = {"v" => :visual, "V" => :visual_line, "ctrl-v" => :visual_block}.fetch(key)
66
+ if @mode == requested
67
+ escape
68
+ else
69
+ @visual_anchor = @visual_head = cursor_position unless visual?
70
+ @mode = requested
71
+ @visual_keys = @command.dup
72
+ update_visual
73
+ @command.clear
74
+ end
75
+ when "d", "c", "y", ">", "<", "="
76
+ @operator, @operator_count, @operator_explicit = key, count, explicit
77
+ when "g", "f", "F", "t", "T", "m", "'", "`", '"', "@", "r"
78
+ @prefix, @prefix_count, @prefix_explicit = key, count, explicit
79
+ when "q"
80
+ if @recording
81
+ save_macro_register(@recording)
82
+ @recording = nil
83
+ @command.clear
84
+ else
85
+ @prefix = "q"
86
+ end
87
+ when "x", "X", "s"
88
+ first = cursor_position
89
+ endpoint = horizontal(first, key == "X" ? -count : count, insertion: true)
90
+ operate(key == "s" ? "c" : "d", [first, endpoint].min...[first, endpoint].max)
91
+ when "D", "C", "Y", "S"
92
+ if %w[Y S].include?(key)
93
+ operate(key == "Y" ? "y" : "c", line_range(count), linewise: true)
94
+ else
95
+ ending = line_end([row_at(cursor_position) + count - 1, last_row].min)
96
+ operate(key == "D" ? "d" : "c", cursor_position...ending)
97
+ end
98
+ when "p", "P" then paste(key == "p", count)
99
+ when "u", "ctrl-r"
100
+ groups = @macro_groups || 0
101
+ groups.times { @editor.buffer.end_undo_group }
102
+ begin
103
+ count.times { key == "u" ? @editor.undo : @editor.redo }
104
+ ensure
105
+ groups.times { @editor.buffer.begin_undo_group }
106
+ end
107
+ @editor.select(normal_offset(@editor.primary.head))
108
+ @command.clear
109
+ when "."
110
+ change = @last_change.dup
111
+ @command.clear
112
+ count.times { replay(change) }
113
+ when "J" then join_lines(count)
114
+ when "~"
115
+ if visual?
116
+ operate_visual("g~")
117
+ else
118
+ first = cursor_position
119
+ ending = horizontal(first, count, insertion: true)
120
+ operate("g~", first...ending, destination: normal_offset(ending))
121
+ end
122
+ when ":", "/", "?"
123
+ @command_line, @command_type = +"", key
124
+ @command_line = +"'<,'>" if key == ":" && visual?
125
+ when "n", "N"
126
+ search_next(reverse: key == "N", count: count)
127
+ @command.clear
128
+ when "*", "#"
129
+ range = text_object("w", inner: true, count: 1)
130
+ if range
131
+ @search = "\\b#{Regexp.escape(@editor.buffer.rope.byteslice(range).to_s)}\\b"
132
+ @search_direction = key == "*" ? 1 : -1
133
+ search_next(count: count)
134
+ end
135
+ @command.clear
136
+ else
137
+ move(key, count, explicit: explicit)
138
+ @command.clear
139
+ end
140
+ rescue Error
141
+ end_group
142
+ @mode, @operator, @prefix = :normal, nil, nil
143
+ raise
144
+ ensure
145
+ @status = @message || (@command_line ? "#{@command_type}#{@command_line}" : @mode.to_s.upcase)
146
+ end
147
+
148
+ private
149
+
150
+ def visual? = VISUAL_MODES.include?(@mode)
151
+ def start_group
152
+ return if @insert_group
153
+ @editor.buffer.begin_undo_group
154
+ @insert_group = true
155
+ end
156
+ def end_group
157
+ return unless @insert_group
158
+ @editor.buffer.end_undo_group
159
+ @insert_group = false
160
+ end
161
+
162
+ def escape
163
+ position = cursor_position
164
+ @last_visual = [@mode, @visual_anchor, @visual_head] if visual?
165
+ @mode, @operator, @prefix, @count, @command = :normal, nil, nil, +"", []
166
+ @register = '"'
167
+ @editor.select(normal_offset(position))
168
+ end
169
+
170
+ def insert_key(key)
171
+ @change_keys << key
172
+ if @insert_register
173
+ @insert_register = false
174
+ value = @registers[key]&.first
175
+ @editor.replace_selections(value, kind: :vim_insert) if value
176
+ return
177
+ end
178
+ case key
179
+ when "esc", "ctrl-c"
180
+ recorded = @change_keys.dup
181
+ if @insert_count > 1
182
+ keys = recorded.drop(@insert_prefix_length)[0...-1]
183
+ repeats, @insert_count = @insert_count - 1, 1
184
+ repeats.times { keys.each { |item| insert_key(item) } }
185
+ end
186
+ position = if @block_insert_start
187
+ @block_insert_keep_start ? @editor.buffer.resolve(@block_insert_start) : @editor.selections.first.head
188
+ else
189
+ @editor.primary.head
190
+ end
191
+ position = previous_offset(position) if !@block_insert_keep_start && position > line_start(row_at(position))
192
+ @editor.buffer.release_anchor(@block_insert_start) if @block_insert_start
193
+ @block_insert_start = nil
194
+ @block_insert_keep_start = false
195
+ @mode = :normal
196
+ @editor.select(normal_offset(position))
197
+ end_group
198
+ @last_change = recorded
199
+ @command.clear
200
+ when "ctrl-r" then @insert_register = true
201
+ when "backspace"
202
+ if @mode == :replace && @replace_stack.last && @replace_stack.last[1] == @editor.primary.head
203
+ first, ending, original = @replace_stack.pop
204
+ @editor.select(first, ending)
205
+ @editor.replace_selections(original, kind: :vim_insert)
206
+ @editor.select(first)
207
+ else
208
+ @editor.delete_backward
209
+ end
210
+ when "delete" then @editor.delete_forward
211
+ when "left", "right", "up", "down" then @editor.move(key.to_sym)
212
+ when "enter" then @editor.insert_text("\n")
213
+ when "tab" then @editor.insert_text(@editor.use_tabs ? "\t" : " " * @editor.tab_size)
214
+ else
215
+ return unless key.grapheme_clusters.length == 1
216
+ auto_pairs = @editor.auto_pairs
217
+ begin
218
+ @editor.auto_pairs = false
219
+ if @mode == :replace
220
+ first = @editor.primary.head
221
+ ending = horizontal(first, 1, insertion: true)
222
+ original = @editor.buffer.rope.byteslice(first...ending).to_s
223
+ @editor.select(first, ending)
224
+ @replace_stack << [first, first + key.bytesize, original]
225
+ end
226
+ @editor.insert_text(key)
227
+ ensure
228
+ @editor.auto_pairs = auto_pairs
229
+ end
230
+ end
231
+ end
232
+
233
+ def enter_insert(key, count = 1)
234
+ start_group
235
+ if @mode == :visual_block && %w[I A].include?(key)
236
+ return enter_block_insert(key, count)
237
+ end
238
+ @change_keys, @insert_count, @replace_stack = @command.dup, count, []
239
+ @insert_prefix_length = @change_keys.length
240
+ case key
241
+ when "a" then @editor.select(horizontal(cursor_position, 1, insertion: true))
242
+ when "I" then @editor.select(first_nonblank(row_at(cursor_position)))
243
+ when "A" then @editor.select(line_end(row_at(cursor_position)))
244
+ when "o"
245
+ @editor.select(line_end(row_at(cursor_position)))
246
+ @editor.insert_text("\n")
247
+ when "O"
248
+ row = row_at(cursor_position)
249
+ indent = @editor.buffer.line(row)[/\A[ \t]*/]
250
+ first = line_start(row)
251
+ @editor.select(first)
252
+ @editor.replace_selections(indent + @editor.buffer.line_ending, kind: :vim_insert)
253
+ @editor.select(first + indent.bytesize)
254
+ end
255
+ @mode = key == "R" ? :replace : :insert
256
+ @command.clear
257
+ end
258
+
259
+ def prefix_key(key)
260
+ prefix, @prefix = @prefix, nil
261
+ count = @prefix_count || 1
262
+ case prefix
263
+ when "g"
264
+ if %w[u U ~].include?(key)
265
+ return operate_visual("g#{key}") if visual?
266
+ @operator, @operator_count, @operator_explicit = "g#{key}", count, @prefix_explicit
267
+ return
268
+ elsif key == "g"
269
+ move("gg", count, explicit: true)
270
+ elsif %w[e E].include?(key)
271
+ move("g#{key}", count)
272
+ elsif key == "v" && @last_visual
273
+ @mode, @visual_anchor, @visual_head = @last_visual
274
+ @visual_anchor, @visual_head = normal_offset(@visual_anchor), normal_offset(@visual_head)
275
+ @visual_keys = ["g", "v"]
276
+ update_visual
277
+ end
278
+ when "m"
279
+ @editor.buffer.release_anchor(@marks[key]) if @marks[key]
280
+ @marks[key] = @editor.buffer.anchor(cursor_position, bias: :left) if key.match?(/\A[a-zA-Z]\z/)
281
+ when "'", "`"
282
+ if @marks[key]
283
+ target = @editor.buffer.resolve(@marks[key])
284
+ target = first_nonblank(row_at(target)) if prefix == "'"
285
+ return operate_motion(target, inclusive: false, linewise: prefix == "'") if @operator
286
+ move_to(target)
287
+ else
288
+ @operator = nil
289
+ end
290
+ when '"'
291
+ @register = key
292
+ return
293
+ when "q"
294
+ if key.match?(/\A[a-zA-Z]\z/)
295
+ @recording = key.downcase
296
+ @macros[@recording] = [] unless key.match?(/[A-Z]/) && @macros[@recording]
297
+ end
298
+ when "@"
299
+ name = key == "@" ? @last_macro : key.downcase
300
+ @last_macro = name
301
+ @command.clear
302
+ keys = @macros[name] || register_keys(register_entry(name)&.first.to_s)
303
+ replay(keys, group: true, count: count)
304
+ when "f", "F", "t", "T"
305
+ target = find_character(key, prefix, count)
306
+ if target
307
+ @last_find = [key, prefix]
308
+ return operate_motion(target, inclusive: %w[f t].include?(prefix)) if @operator
309
+ move_to(target)
310
+ else
311
+ @operator = nil
312
+ end
313
+ when "r"
314
+ return replace_visual(key) if visual?
315
+ if key.grapheme_clusters.length == 1 || key == "enter"
316
+ first = cursor_position
317
+ ending = horizontal(first, count, insertion: true)
318
+ if @editor.buffer.rope.byteslice(first...ending).to_s.grapheme_clusters.length == count
319
+ @editor.select(first, ending)
320
+ @editor.replace_selections(key == "enter" ? @editor.buffer.line_ending : key * count, kind: :vim)
321
+ @editor.select(normal_offset(key == "enter" ? first + @editor.buffer.line_ending.bytesize : previous_offset(first + key.bytesize * count)))
322
+ finish_change
323
+ end
324
+ end
325
+ when "i", "a"
326
+ range = text_object(key, inner: prefix == "i", count: count)
327
+ if range
328
+ if visual?
329
+ @visual_anchor, @visual_head = range.begin, previous_offset(range.end)
330
+ update_visual
331
+ else
332
+ operate(@operator, range, linewise: key == "p", destination: @operator == "y" ? range.begin : nil)
333
+ end
334
+ else
335
+ @operator = nil
336
+ end
337
+ return
338
+ when "operator_g"
339
+ if %w[g e E].include?(key)
340
+ return operate_key("g#{key}", count, @prefix_explicit, multiplied: true)
341
+ end
342
+ @operator = nil
343
+ end
344
+ @command.clear
345
+ end
346
+
347
+ def operate_key(key, count, explicit = false, multiplied: false)
348
+ count *= @operator_count unless multiplied
349
+ if key == @operator || (%w[gu gU g~].include?(@operator) && key == @operator[-1])
350
+ operate(@operator, line_range(count), linewise: true)
351
+ elsif %w[i a f F t T ' `].include?(key)
352
+ @prefix, @prefix_count = key, count
353
+ elsif key == "g"
354
+ @prefix, @prefix_count, @prefix_explicit = "operator_g", count, explicit || @operator_explicit
355
+ elsif %w[/ ?].include?(key)
356
+ @command_line, @command_type, @search_count = +"", key, count
357
+ else
358
+ effective = @operator == "c" && %w[w W].include?(key) && !character_at(cursor_position).match?(/\s/) ? (key == "w" ? "ce" : "cE") : key
359
+ motion = motion_target(effective, count, explicit: explicit || @operator_explicit)
360
+ if motion
361
+ operate_motion(*motion)
362
+ else
363
+ @operator = nil
364
+ @command.clear
365
+ end
366
+ end
367
+ end
368
+
369
+ def operate_motion(target, options = {}, inclusive: options.fetch(:inclusive, false), linewise: options.fetch(:linewise, false))
370
+ first, last = [cursor_position, target].minmax
371
+ if linewise
372
+ ending_row = row_at(last)
373
+ ending = ending_row + 1 < @editor.buffer.line_count ? line_start(ending_row + 1) : @editor.buffer.rope.bytesize
374
+ destination = first if %w[y gu gU g~].include?(@operator)
375
+ return operate(@operator, line_start(row_at(first))...ending, linewise: true, destination: destination)
376
+ end
377
+ if inclusive
378
+ last = next_offset(last)
379
+ elsif first == last && %w[gu gU g~].include?(@operator)
380
+ last = next_offset(last)
381
+ elsif !linewise && target > cursor_position && row_at(target) > row_at(cursor_position) && target == line_start(row_at(target))
382
+ if cursor_position <= first_nonblank(row_at(cursor_position))
383
+ linewise = true
384
+ else
385
+ last = line_end(row_at(target) - 1)
386
+ end
387
+ end
388
+ operate(@operator, first...last, linewise: linewise)
389
+ end
390
+
391
+ def finish_change
392
+ @last_change = @command.dup unless @command.empty?
393
+ @command.clear
394
+ end
395
+
396
+ def replay(keys, group: false, count: 1)
397
+ raise Error, "macro recursion limit" if @play_depth >= 10
398
+ @replay_remaining = 10_000 if @play_depth.zero?
399
+ @play_depth += 1
400
+ @editor.buffer.begin_undo_group if group
401
+ @macro_groups = (@macro_groups || 0) + 1 if group
402
+ begin
403
+ count.times do
404
+ keys.each do |key|
405
+ raise Error, "macro execution limit" if @replay_remaining.zero?
406
+ @replay_remaining -= 1
407
+ feed(key)
408
+ end
409
+ end
410
+ ensure
411
+ @editor.buffer.end_undo_group if group
412
+ @macro_groups -= 1 if group
413
+ @play_depth -= 1
414
+ end
415
+ end
416
+ end
417
+ end
418
+
419
+ require_relative "vim/motionable"
420
+ require_relative "vim/text_object_selectable"
421
+ require_relative "vim/operator_capable"
422
+ require_relative "vim/commandable"
423
+ Canopus::Vim.include Canopus::Vim::Motionable
424
+ Canopus::Vim.include Canopus::Vim::TextObjectSelectable
425
+ Canopus::Vim.include Canopus::Vim::OperatorCapable
426
+ Canopus::Vim.include Canopus::Vim::Commandable
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canopus
4
+ module Workspace::Edit
5
+ module Executable
6
+ def apply
7
+ return {"applied" => false, "failureReason" => "workspace edit plan has already been used"} if @used
8
+ @used = true
9
+ verify_unchanged
10
+ @operations.each do |operation|
11
+ @failed_change = operation[:index]
12
+ execute_resource(operation)
13
+ end
14
+ # All text edits were checked against the ordered virtual filesystem. They
15
+ # remain unsaved buffer edits; no temporary draft is written into a file.
16
+ @memory_started = true
17
+ @failed_change = nil
18
+ update_buffers
19
+ @workspace.refresh_files
20
+ result = {"applied" => true}
21
+ result["recoveryPaths"] = @recoveries.dup unless @recoveries.empty?
22
+ announce_recovery
23
+ result
24
+ rescue StandardError => error
25
+ failures = @memory_started ? [] : rollback
26
+ announce_recovery
27
+ result = {"applied" => false, "failureReason" => ([error.message] + failures).join("; "),
28
+ "recoveryPaths" => @recoveries.select { |path| File.exist?(path) || File.symlink?(path) }}
29
+ result["failureReason"] += "; recoverable files: #{File.join(@root, '.canopus', 'trash')}" unless result["recoveryPaths"].empty?
30
+ result["failedChange"] = @failed_change if @failed_change
31
+ result
32
+ ensure
33
+ close
34
+ end
35
+
36
+ private
37
+
38
+ def verify_unchanged
39
+ raise Error, "resource changed while preparing workspace edit" unless @observed.all? { |path, value| fingerprint(path) == value }
40
+ unless @originals.all? { |buffer, (rope, version, path)| buffer.rope.equal?(rope) && buffer.version == version && buffer.path == path }
41
+ raise Error, "document changed while preparing workspace edit"
42
+ end
43
+ end
44
+
45
+ def recovery_path(path)
46
+ @workspace.project_path(".canopus/trash")
47
+ directory = File.join(@root, ".canopus", "trash")
48
+ FileUtils.mkdir_p(directory)
49
+ name = File.basename(path).byteslice(0, 160).scrub
50
+ File.join(directory, "lsp-#{Time.now.utc.strftime('%Y%m%dT%H%M%S')}-#{SecureRandom.hex(8)}-#{name}")
51
+ end
52
+
53
+ def backup(path)
54
+ destination = recovery_path(path)
55
+ File.rename(path, destination)
56
+ @recoveries << destination
57
+ @journal << [:restore, destination, path]
58
+ destination
59
+ end
60
+
61
+ def execute_resource(operation)
62
+ path = operation.fetch(:path)
63
+ [path, operation[:source]].compact.each do |target|
64
+ parent = File.realpath(File.dirname(target))
65
+ raise Error, "resource parent moved outside project" unless parent == @root || parent.start_with?(@root + File::SEPARATOR)
66
+ end
67
+ backup(path) if operation[:backup]
68
+ case operation[:kind]
69
+ when :create
70
+ operation[:directory] ? Dir.mkdir(path) : File.open(path, File::CREAT | File::EXCL | File::WRONLY, 0o644, &:close)
71
+ @journal << [:created, path]
72
+ when :rename
73
+ raise Error, "rename destination appeared during workspace edit" if File.exist?(path) || File.symlink?(path)
74
+ File.rename(operation.fetch(:source), path)
75
+ @journal << [:restore, path, operation.fetch(:source)]
76
+ when :delete then backup(path)
77
+ end
78
+ end
79
+
80
+ def rollback
81
+ failures = []
82
+ @journal.reverse_each do |kind, source, target|
83
+ next unless File.exist?(source) || File.symlink?(source)
84
+ if kind == :created
85
+ target = recovery_path(source)
86
+ @recoveries << target
87
+ elsif File.exist?(target) || File.symlink?(target)
88
+ raise Error, "rollback destination now exists: #{target}"
89
+ end
90
+ File.rename(source, target)
91
+ rescue StandardError => error
92
+ failures << "recovery required: #{error.message}"
93
+ end
94
+ failures
95
+ end
96
+
97
+ def update_buffers
98
+ deleted = @operations.select { |operation| operation[:kind] == :delete }.flat_map { |operation| operation[:nodes] }.filter_map(&:buffer)
99
+ deleted.uniq.each do |buffer|
100
+ @workspace.panes.flat_map(&:editors).select { |editor| editor.buffer.equal?(buffer) }.each { |editor| @workspace.close_editor(editor, discard: true) }
101
+ @workspace.close_language_documents(buffer)
102
+ @workspace.buffers.delete_if { |_, value| value.equal?(buffer) }
103
+ buffer.close
104
+ end
105
+ @nodes.values.compact.uniq.each do |node|
106
+ buffer = node.buffer
107
+ next unless buffer
108
+ if buffer.path != node.path
109
+ @workspace.invalidate_git
110
+ @workspace.close_language_documents(buffer)
111
+ @workspace.buffers.delete_if { |_, value| value.equal?(buffer) }
112
+ buffer.relocate(node.path)
113
+ @workspace.panes.flat_map(&:editors).each do |editor|
114
+ next unless editor.buffer.equal?(buffer)
115
+ editor.language = @workspace.send(:definition_for, node.path)
116
+ @workspace.send(:apply_editor_settings, editor)
117
+ end
118
+ end
119
+ @workspace.buffers[node.path] = buffer
120
+ buffer.reload if node.reset || (node.origin.nil? && node.exists)
121
+ buffer.begin_undo_group
122
+ begin
123
+ node.edits.each do |changes, index|
124
+ @failed_change = index
125
+ buffer.edit(changes, kind: :lsp)
126
+ end
127
+ ensure
128
+ buffer.end_undo_group
129
+ end
130
+ end
131
+ end
132
+
133
+ def announce_recovery
134
+ paths = @recoveries.select { |path| File.exist?(path) || File.symlink?(path) }
135
+ @workspace.message = "Language server resource backups: #{paths.join(', ')}" unless paths.empty?
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canopus
4
+ module Workspace::Edit
5
+ Node = Struct.new(:origin, :path, :kind, :exists, :buffer, :rope, :version, :edits, :reset, keyword_init: true)
6
+ private_constant :Node
7
+ end
8
+ end