ruvim 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 (66) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/test.yml +15 -0
  3. data/README.md +135 -0
  4. data/Rakefile +36 -0
  5. data/docs/binding.md +125 -0
  6. data/docs/command.md +306 -0
  7. data/docs/config.md +155 -0
  8. data/docs/done.md +112 -0
  9. data/docs/plugin.md +559 -0
  10. data/docs/spec.md +655 -0
  11. data/docs/todo.md +63 -0
  12. data/docs/tutorial.md +490 -0
  13. data/docs/vim_diff.md +179 -0
  14. data/exe/ruvim +6 -0
  15. data/lib/ruvim/app.rb +1600 -0
  16. data/lib/ruvim/buffer.rb +421 -0
  17. data/lib/ruvim/cli.rb +264 -0
  18. data/lib/ruvim/clipboard.rb +73 -0
  19. data/lib/ruvim/command_invocation.rb +14 -0
  20. data/lib/ruvim/command_line.rb +63 -0
  21. data/lib/ruvim/command_registry.rb +38 -0
  22. data/lib/ruvim/config_dsl.rb +134 -0
  23. data/lib/ruvim/config_loader.rb +68 -0
  24. data/lib/ruvim/context.rb +26 -0
  25. data/lib/ruvim/dispatcher.rb +120 -0
  26. data/lib/ruvim/display_width.rb +110 -0
  27. data/lib/ruvim/editor.rb +1025 -0
  28. data/lib/ruvim/ex_command_registry.rb +80 -0
  29. data/lib/ruvim/global_commands.rb +1889 -0
  30. data/lib/ruvim/highlighter.rb +52 -0
  31. data/lib/ruvim/input.rb +66 -0
  32. data/lib/ruvim/keymap_manager.rb +96 -0
  33. data/lib/ruvim/screen.rb +452 -0
  34. data/lib/ruvim/terminal.rb +30 -0
  35. data/lib/ruvim/text_metrics.rb +96 -0
  36. data/lib/ruvim/version.rb +5 -0
  37. data/lib/ruvim/window.rb +71 -0
  38. data/lib/ruvim.rb +30 -0
  39. data/sig/ruvim.rbs +4 -0
  40. data/test/app_completion_test.rb +39 -0
  41. data/test/app_dot_repeat_test.rb +54 -0
  42. data/test/app_motion_test.rb +73 -0
  43. data/test/app_register_test.rb +47 -0
  44. data/test/app_scenario_test.rb +77 -0
  45. data/test/app_startup_test.rb +199 -0
  46. data/test/app_text_object_test.rb +54 -0
  47. data/test/app_unicode_behavior_test.rb +66 -0
  48. data/test/buffer_test.rb +72 -0
  49. data/test/cli_test.rb +165 -0
  50. data/test/config_dsl_test.rb +78 -0
  51. data/test/dispatcher_test.rb +124 -0
  52. data/test/editor_mark_test.rb +69 -0
  53. data/test/editor_register_test.rb +64 -0
  54. data/test/fixtures/render_basic_snapshot.txt +8 -0
  55. data/test/fixtures/render_basic_snapshot_nonumber.txt +8 -0
  56. data/test/fixtures/render_unicode_scrolled_snapshot.txt +7 -0
  57. data/test/highlighter_test.rb +16 -0
  58. data/test/input_screen_integration_test.rb +69 -0
  59. data/test/keymap_manager_test.rb +48 -0
  60. data/test/render_snapshot_test.rb +70 -0
  61. data/test/screen_test.rb +123 -0
  62. data/test/search_option_test.rb +39 -0
  63. data/test/test_helper.rb +15 -0
  64. data/test/text_metrics_test.rb +42 -0
  65. data/test/window_test.rb +21 -0
  66. metadata +106 -0
data/lib/ruvim/app.rb ADDED
@@ -0,0 +1,1600 @@
1
+ module RuVim
2
+ class App
3
+ def initialize(path: nil, paths: nil, stdin: STDIN, stdout: STDOUT, pre_config_actions: [], startup_actions: [], clean: false, skip_user_config: false, config_path: nil, readonly: false, diff_mode: false, quickfix_errorfile: nil, session_file: nil, nomodifiable: false, restricted: false, verbose_level: 0, verbose_io: STDERR, startup_time_path: nil, startup_open_layout: nil, startup_open_count: nil)
4
+ @editor = Editor.new
5
+ @terminal = Terminal.new(stdin:, stdout:)
6
+ @input = Input.new(stdin:)
7
+ @screen = Screen.new(terminal: @terminal)
8
+ @dispatcher = Dispatcher.new
9
+ @keymaps = KeymapManager.new
10
+ @signal_r, @signal_w = IO.pipe
11
+ @cmdline_history = Hash.new { |h, k| h[k] = [] }
12
+ @cmdline_history_index = nil
13
+ @needs_redraw = true
14
+ @clean_mode = clean
15
+ @skip_user_config = skip_user_config
16
+ @config_path = config_path
17
+ @startup_readonly = readonly
18
+ @startup_diff_mode = diff_mode
19
+ @startup_quickfix_errorfile = quickfix_errorfile
20
+ @startup_session_file = session_file
21
+ @startup_nomodifiable = nomodifiable
22
+ @restricted_mode = restricted
23
+ @verbose_level = verbose_level.to_i
24
+ @verbose_io = verbose_io
25
+ @startup_time_path = startup_time_path
26
+ @startup_time_origin = monotonic_now
27
+ @startup_timeline = []
28
+ @startup_open_layout = startup_open_layout
29
+ @startup_open_count = startup_open_count
30
+ @editor.restricted_mode = @restricted_mode
31
+
32
+ startup_mark("init.start")
33
+ register_builtins!
34
+ bind_default_keys!
35
+ init_config_loader!
36
+ @editor.ensure_bootstrap_buffer!
37
+ verbose_log(1, "startup: run_pre_config_actions count=#{Array(pre_config_actions).length}")
38
+ run_startup_actions!(pre_config_actions, log_prefix: "pre-config")
39
+ startup_mark("pre_config_actions.done")
40
+ verbose_log(1, "startup: load_user_config")
41
+ load_user_config!
42
+ startup_mark("config.loaded")
43
+ install_signal_handlers
44
+ startup_mark("signals.installed")
45
+
46
+ startup_paths = Array(paths || path).compact
47
+ if startup_paths.empty?
48
+ verbose_log(1, "startup: intro")
49
+ @editor.show_intro_buffer_if_applicable!
50
+ else
51
+ verbose_log(1, "startup: open_paths #{startup_paths.inspect} layout=#{@startup_open_layout || :single}")
52
+ open_startup_paths!(startup_paths)
53
+ end
54
+ startup_mark("buffers.opened")
55
+ verbose_log(1, "startup: load_current_ftplugin")
56
+ load_current_ftplugin!
57
+ startup_mark("ftplugin.loaded")
58
+ apply_startup_compat_mode_messages!
59
+ verbose_log(1, "startup: run_startup_actions count=#{Array(startup_actions).length}")
60
+ run_startup_actions!(startup_actions)
61
+ startup_mark("startup_actions.done")
62
+ write_startuptime_log!
63
+ end
64
+
65
+ def run
66
+ @terminal.with_ui do
67
+ loop do
68
+ if @needs_redraw
69
+ @screen.render(@editor)
70
+ @needs_redraw = false
71
+ end
72
+ break unless @editor.running?
73
+
74
+ key = @input.read_key(wakeup_ios: [@signal_r])
75
+ next if key.nil?
76
+
77
+ handle_key(key)
78
+ @needs_redraw = true
79
+ end
80
+ end
81
+ end
82
+
83
+ def run_startup_actions!(actions, log_prefix: "startup")
84
+ Array(actions).each do |action|
85
+ run_startup_action!(action, log_prefix:)
86
+ break unless @editor.running?
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ def register_builtins!
93
+ cmd = CommandRegistry.instance
94
+ ex = ExCommandRegistry.instance
95
+
96
+ register_internal_unless(cmd, "cursor.left", call: :cursor_left, desc: "Move cursor left")
97
+ register_internal_unless(cmd, "cursor.right", call: :cursor_right, desc: "Move cursor right")
98
+ register_internal_unless(cmd, "cursor.up", call: :cursor_up, desc: "Move cursor up")
99
+ register_internal_unless(cmd, "cursor.down", call: :cursor_down, desc: "Move cursor down")
100
+ register_internal_unless(cmd, "cursor.page_up", call: :cursor_page_up, desc: "Move one page up")
101
+ register_internal_unless(cmd, "cursor.page_down", call: :cursor_page_down, desc: "Move one page down")
102
+ register_internal_unless(cmd, "cursor.line_start", call: :cursor_line_start, desc: "Move to column 1")
103
+ register_internal_unless(cmd, "cursor.line_end", call: :cursor_line_end, desc: "Move to end of line")
104
+ register_internal_unless(cmd, "cursor.first_nonblank", call: :cursor_first_nonblank, desc: "Move to first nonblank")
105
+ register_internal_unless(cmd, "cursor.buffer_start", call: :cursor_buffer_start, desc: "Move to start of buffer")
106
+ register_internal_unless(cmd, "cursor.buffer_end", call: :cursor_buffer_end, desc: "Move to end of buffer")
107
+ register_internal_unless(cmd, "cursor.word_forward", call: :cursor_word_forward, desc: "Move to next word")
108
+ register_internal_unless(cmd, "cursor.word_backward", call: :cursor_word_backward, desc: "Move to previous word")
109
+ register_internal_unless(cmd, "cursor.word_end", call: :cursor_word_end, desc: "Move to end of word")
110
+ register_internal_unless(cmd, "cursor.match_bracket", call: :cursor_match_bracket, desc: "Jump to matching bracket")
111
+ register_internal_unless(cmd, "mode.insert", call: :enter_insert_mode, desc: "Enter insert mode")
112
+ register_internal_unless(cmd, "mode.append", call: :append_mode, desc: "Append after cursor")
113
+ register_internal_unless(cmd, "mode.append_line_end", call: :append_line_end_mode, desc: "Append at line end")
114
+ register_internal_unless(cmd, "mode.insert_nonblank", call: :insert_line_start_nonblank_mode, desc: "Insert at first nonblank")
115
+ register_internal_unless(cmd, "mode.open_below", call: :open_line_below, desc: "Open line below")
116
+ register_internal_unless(cmd, "mode.open_above", call: :open_line_above, desc: "Open line above")
117
+ register_internal_unless(cmd, "mode.visual_char", call: :enter_visual_char_mode, desc: "Enter visual char mode")
118
+ register_internal_unless(cmd, "mode.visual_line", call: :enter_visual_line_mode, desc: "Enter visual line mode")
119
+ register_internal_unless(cmd, "mode.visual_block", call: :enter_visual_block_mode, desc: "Enter visual block mode")
120
+ register_internal_unless(cmd, "window.split", call: :window_split, desc: "Horizontal split")
121
+ register_internal_unless(cmd, "window.vsplit", call: :window_vsplit, desc: "Vertical split")
122
+ register_internal_unless(cmd, "window.focus_next", call: :window_focus_next, desc: "Next window")
123
+ register_internal_unless(cmd, "window.focus_left", call: :window_focus_left, desc: "Focus left window")
124
+ register_internal_unless(cmd, "window.focus_right", call: :window_focus_right, desc: "Focus right window")
125
+ register_internal_unless(cmd, "window.focus_up", call: :window_focus_up, desc: "Focus upper window")
126
+ register_internal_unless(cmd, "window.focus_down", call: :window_focus_down, desc: "Focus lower window")
127
+ register_internal_unless(cmd, "mode.command_line", call: :enter_command_line_mode, desc: "Enter command-line mode")
128
+ register_internal_unless(cmd, "mode.search_forward", call: :enter_search_forward_mode, desc: "Enter / search")
129
+ register_internal_unless(cmd, "mode.search_backward", call: :enter_search_backward_mode, desc: "Enter ? search")
130
+ register_internal_unless(cmd, "buffer.delete_char", call: :delete_char, desc: "Delete char under cursor")
131
+ register_internal_unless(cmd, "buffer.delete_line", call: :delete_line, desc: "Delete current line")
132
+ register_internal_unless(cmd, "buffer.delete_motion", call: :delete_motion, desc: "Delete by motion")
133
+ register_internal_unless(cmd, "buffer.change_motion", call: :change_motion, desc: "Change by motion")
134
+ register_internal_unless(cmd, "buffer.change_line", call: :change_line, desc: "Change line(s)")
135
+ register_internal_unless(cmd, "buffer.yank_line", call: :yank_line, desc: "Yank line(s)")
136
+ register_internal_unless(cmd, "buffer.yank_motion", call: :yank_motion, desc: "Yank by motion")
137
+ register_internal_unless(cmd, "buffer.paste_after", call: :paste_after, desc: "Paste after")
138
+ register_internal_unless(cmd, "buffer.paste_before", call: :paste_before, desc: "Paste before")
139
+ register_internal_unless(cmd, "buffer.visual_yank", call: :visual_yank, desc: "Yank visual selection")
140
+ register_internal_unless(cmd, "buffer.visual_delete", call: :visual_delete, desc: "Delete visual selection")
141
+ register_internal_unless(cmd, "buffer.visual_select_text_object", call: :visual_select_text_object, desc: "Select visual text object")
142
+ register_internal_unless(cmd, "buffer.undo", call: :buffer_undo, desc: "Undo")
143
+ register_internal_unless(cmd, "buffer.redo", call: :buffer_redo, desc: "Redo")
144
+ register_internal_unless(cmd, "search.next", call: :search_next, desc: "Repeat search")
145
+ register_internal_unless(cmd, "search.prev", call: :search_prev, desc: "Repeat search backward")
146
+ register_internal_unless(cmd, "search.word_forward", call: :search_word_forward, desc: "Search word forward")
147
+ register_internal_unless(cmd, "search.word_backward", call: :search_word_backward, desc: "Search word backward")
148
+ register_internal_unless(cmd, "search.word_forward_partial", call: :search_word_forward_partial, desc: "Search partial word forward")
149
+ register_internal_unless(cmd, "search.word_backward_partial", call: :search_word_backward_partial, desc: "Search partial word backward")
150
+ register_internal_unless(cmd, "mark.set", call: :mark_set, desc: "Set mark")
151
+ register_internal_unless(cmd, "mark.jump", call: :mark_jump, desc: "Jump to mark")
152
+ register_internal_unless(cmd, "jump.older", call: :jump_older, desc: "Jump older")
153
+ register_internal_unless(cmd, "jump.newer", call: :jump_newer, desc: "Jump newer")
154
+ register_internal_unless(cmd, "editor.buffer_next", call: :buffer_next, desc: "Next buffer")
155
+ register_internal_unless(cmd, "editor.buffer_prev", call: :buffer_prev, desc: "Previous buffer")
156
+ register_internal_unless(cmd, "buffer.replace_char", call: :replace_char, desc: "Replace single char")
157
+ register_internal_unless(cmd, "ui.clear_message", call: :clear_message, desc: "Clear message")
158
+
159
+ register_ex_unless(ex, "w", call: :file_write, aliases: %w[write], desc: "Write current buffer", nargs: :maybe_one, bang: true)
160
+ register_ex_unless(ex, "q", call: :app_quit, aliases: %w[quit], desc: "Quit", nargs: 0, bang: true)
161
+ register_ex_unless(ex, "wq", call: :file_write_quit, desc: "Write and quit", nargs: :maybe_one, bang: true)
162
+ register_ex_unless(ex, "e", call: :file_edit, aliases: %w[edit], desc: "Edit file / reload", nargs: :maybe_one, bang: true)
163
+ register_ex_unless(ex, "help", call: :ex_help, desc: "Show help / topics", nargs: :any)
164
+ register_ex_unless(ex, "command", call: :ex_define_command, desc: "Define user command", nargs: :any, bang: true)
165
+ register_ex_unless(ex, "ruby", call: :ex_ruby, aliases: %w[rb], desc: "Evaluate Ruby", nargs: :any, bang: false)
166
+ register_ex_unless(ex, "ls", call: :buffer_list, aliases: %w[buffers], desc: "List buffers", nargs: 0)
167
+ register_ex_unless(ex, "bnext", call: :buffer_next, aliases: %w[bn], desc: "Next buffer", nargs: 0, bang: true)
168
+ register_ex_unless(ex, "bprev", call: :buffer_prev, aliases: %w[bp], desc: "Previous buffer", nargs: 0, bang: true)
169
+ register_ex_unless(ex, "buffer", call: :buffer_switch, aliases: %w[b], desc: "Switch buffer", nargs: 1, bang: true)
170
+ register_ex_unless(ex, "commands", call: :ex_commands, desc: "List Ex commands", nargs: 0)
171
+ register_ex_unless(ex, "set", call: :ex_set, desc: "Set options", nargs: :any)
172
+ register_ex_unless(ex, "setlocal", call: :ex_setlocal, desc: "Set window/buffer local option", nargs: :any)
173
+ register_ex_unless(ex, "setglobal", call: :ex_setglobal, desc: "Set global option", nargs: :any)
174
+ register_ex_unless(ex, "split", call: :window_split, desc: "Horizontal split", nargs: 0)
175
+ register_ex_unless(ex, "vsplit", call: :window_vsplit, desc: "Vertical split", nargs: 0)
176
+ register_ex_unless(ex, "tabnew", call: :tab_new, desc: "New tab", nargs: :maybe_one)
177
+ register_ex_unless(ex, "tabnext", call: :tab_next, aliases: %w[tabn], desc: "Next tab", nargs: 0)
178
+ register_ex_unless(ex, "tabprev", call: :tab_prev, aliases: %w[tabp], desc: "Prev tab", nargs: 0)
179
+ register_ex_unless(ex, "vimgrep", call: :ex_vimgrep, desc: "Populate quickfix from regex (minimal)", nargs: :any)
180
+ register_ex_unless(ex, "lvimgrep", call: :ex_lvimgrep, desc: "Populate location list from regex (minimal)", nargs: :any)
181
+ register_ex_unless(ex, "copen", call: :ex_copen, desc: "Open quickfix list", nargs: 0)
182
+ register_ex_unless(ex, "cclose", call: :ex_cclose, desc: "Close quickfix window", nargs: 0)
183
+ register_ex_unless(ex, "cnext", call: :ex_cnext, aliases: %w[cn], desc: "Next quickfix item", nargs: 0)
184
+ register_ex_unless(ex, "cprev", call: :ex_cprev, aliases: %w[cp], desc: "Prev quickfix item", nargs: 0)
185
+ register_ex_unless(ex, "lopen", call: :ex_lopen, desc: "Open location list", nargs: 0)
186
+ register_ex_unless(ex, "lclose", call: :ex_lclose, desc: "Close location list window", nargs: 0)
187
+ register_ex_unless(ex, "lnext", call: :ex_lnext, aliases: %w[ln], desc: "Next location item", nargs: 0)
188
+ register_ex_unless(ex, "lprev", call: :ex_lprev, aliases: %w[lp], desc: "Prev location item", nargs: 0)
189
+ end
190
+
191
+ def bind_default_keys!
192
+ @keymaps.bind(:normal, "h", "cursor.left")
193
+ @keymaps.bind(:normal, "j", "cursor.down")
194
+ @keymaps.bind(:normal, "k", "cursor.up")
195
+ @keymaps.bind(:normal, "l", "cursor.right")
196
+ @keymaps.bind(:normal, "0", "cursor.line_start")
197
+ @keymaps.bind(:normal, "$", "cursor.line_end")
198
+ @keymaps.bind(:normal, "^", "cursor.first_nonblank")
199
+ @keymaps.bind(:normal, "gg", "cursor.buffer_start")
200
+ @keymaps.bind(:normal, "G", "cursor.buffer_end")
201
+ @keymaps.bind(:normal, "w", "cursor.word_forward")
202
+ @keymaps.bind(:normal, "b", "cursor.word_backward")
203
+ @keymaps.bind(:normal, "e", "cursor.word_end")
204
+ @keymaps.bind(:normal, "%", "cursor.match_bracket")
205
+ @keymaps.bind(:normal, "i", "mode.insert")
206
+ @keymaps.bind(:normal, "a", "mode.append")
207
+ @keymaps.bind(:normal, "A", "mode.append_line_end")
208
+ @keymaps.bind(:normal, "I", "mode.insert_nonblank")
209
+ @keymaps.bind(:normal, "o", "mode.open_below")
210
+ @keymaps.bind(:normal, "O", "mode.open_above")
211
+ @keymaps.bind(:normal, "v", "mode.visual_char")
212
+ @keymaps.bind(:normal, "V", "mode.visual_line")
213
+ @keymaps.bind(:normal, ["<C-v>"], "mode.visual_block")
214
+ @keymaps.bind(:normal, ["<C-w>", "w"], "window.focus_next")
215
+ @keymaps.bind(:normal, ["<C-w>", "h"], "window.focus_left")
216
+ @keymaps.bind(:normal, ["<C-w>", "j"], "window.focus_down")
217
+ @keymaps.bind(:normal, ["<C-w>", "k"], "window.focus_up")
218
+ @keymaps.bind(:normal, ["<C-w>", "l"], "window.focus_right")
219
+ @keymaps.bind(:normal, ":", "mode.command_line")
220
+ @keymaps.bind(:normal, "/", "mode.search_forward")
221
+ @keymaps.bind(:normal, "?", "mode.search_backward")
222
+ @keymaps.bind(:normal, "x", "buffer.delete_char")
223
+ @keymaps.bind(:normal, "p", "buffer.paste_after")
224
+ @keymaps.bind(:normal, "P", "buffer.paste_before")
225
+ @keymaps.bind(:normal, "u", "buffer.undo")
226
+ @keymaps.bind(:normal, ["<C-r>"], "buffer.redo")
227
+ @keymaps.bind(:normal, ["<C-o>"], "jump.older")
228
+ @keymaps.bind(:normal, ["<C-i>"], "jump.newer")
229
+ @keymaps.bind(:normal, "n", "search.next")
230
+ @keymaps.bind(:normal, "N", "search.prev")
231
+ @keymaps.bind(:normal, "*", "search.word_forward")
232
+ @keymaps.bind(:normal, "#", "search.word_backward")
233
+ @keymaps.bind(:normal, "g*", "search.word_forward_partial")
234
+ @keymaps.bind(:normal, "g#", "search.word_backward_partial")
235
+ @keymaps.bind(:normal, "\e", "ui.clear_message")
236
+ end
237
+
238
+ def handle_key(key)
239
+ @skip_record_for_current_key = false
240
+ append_dot_change_capture_key(key)
241
+ if key == :ctrl_c
242
+ handle_ctrl_c
243
+ record_macro_key_if_needed(key)
244
+ return
245
+ end
246
+
247
+ case @editor.mode
248
+ when :insert
249
+ handle_insert_key(key)
250
+ when :command_line
251
+ handle_command_line_key(key)
252
+ when :visual_char, :visual_line, :visual_block
253
+ handle_visual_key(key)
254
+ else
255
+ handle_normal_key(key)
256
+ end
257
+ load_current_ftplugin!
258
+ record_macro_key_if_needed(key)
259
+ end
260
+
261
+ def handle_normal_key(key)
262
+ if arrow_key?(key)
263
+ invoke_arrow(key)
264
+ return
265
+ end
266
+
267
+ if paging_key?(key)
268
+ invoke_page_key(key)
269
+ return
270
+ end
271
+
272
+ if digit_key?(key) && count_digit_allowed?(key)
273
+ @editor.pending_count = (@editor.pending_count.to_s + key).to_i
274
+ @editor.echo(@editor.pending_count.to_s)
275
+ @pending_keys = []
276
+ return
277
+ end
278
+
279
+ token = normalize_key_token(key)
280
+ return if token.nil?
281
+
282
+ if @operator_pending
283
+ handle_operator_pending_key(token)
284
+ return
285
+ end
286
+
287
+ if @register_pending
288
+ finish_register_pending(token)
289
+ return
290
+ end
291
+
292
+ if @mark_pending
293
+ finish_mark_pending(token)
294
+ return
295
+ end
296
+
297
+ if @jump_pending
298
+ finish_jump_pending(token)
299
+ return
300
+ end
301
+
302
+ if @macro_record_pending
303
+ finish_macro_record_pending(token)
304
+ return
305
+ end
306
+
307
+ if @macro_play_pending
308
+ finish_macro_play_pending(token)
309
+ return
310
+ end
311
+
312
+ if @replace_pending
313
+ handle_replace_pending_key(token)
314
+ return
315
+ end
316
+
317
+ if @find_pending
318
+ finish_find_pending(token)
319
+ return
320
+ end
321
+
322
+ if token == "\""
323
+ start_register_pending
324
+ return
325
+ end
326
+
327
+ if token == "d"
328
+ start_operator_pending(:delete)
329
+ return
330
+ end
331
+
332
+ if token == "y"
333
+ start_operator_pending(:yank)
334
+ return
335
+ end
336
+
337
+ if token == "c"
338
+ start_operator_pending(:change)
339
+ return
340
+ end
341
+
342
+ if token == "r"
343
+ start_replace_pending
344
+ return
345
+ end
346
+
347
+ if %w[f F t T].include?(token)
348
+ start_find_pending(token)
349
+ return
350
+ end
351
+
352
+ if token == ";"
353
+ repeat_last_find(reverse: false)
354
+ return
355
+ end
356
+
357
+ if token == ","
358
+ repeat_last_find(reverse: true)
359
+ return
360
+ end
361
+
362
+ if token == "."
363
+ repeat_last_change
364
+ return
365
+ end
366
+
367
+ if token == "q"
368
+ if @editor.macro_recording?
369
+ stop_macro_recording
370
+ else
371
+ start_macro_record_pending
372
+ end
373
+ return
374
+ end
375
+
376
+ if token == "@"
377
+ start_macro_play_pending
378
+ return
379
+ end
380
+
381
+ if token == "m"
382
+ start_mark_pending
383
+ return
384
+ end
385
+
386
+ if token == "'"
387
+ start_jump_pending(linewise: true, repeat_token: "'")
388
+ return
389
+ end
390
+
391
+ if token == "`"
392
+ start_jump_pending(linewise: false, repeat_token: "`")
393
+ return
394
+ end
395
+
396
+ @pending_keys ||= []
397
+ @pending_keys << token
398
+
399
+ match = @keymaps.resolve_with_context(:normal, @pending_keys, editor: @editor)
400
+ case match.status
401
+ when :pending, :ambiguous
402
+ return
403
+ when :match
404
+ matched_keys = @pending_keys.dup
405
+ repeat_count = @editor.pending_count || 1
406
+ invocation = dup_invocation(match.invocation)
407
+ invocation.count = repeat_count
408
+ @dispatcher.dispatch(@editor, invocation)
409
+ maybe_record_simple_dot_change(invocation, matched_keys, repeat_count)
410
+ else
411
+ @editor.echo_error("Unknown key: #{@pending_keys.join}")
412
+ end
413
+ @editor.pending_count = nil
414
+ @pending_keys = []
415
+ end
416
+
417
+ def handle_insert_key(key)
418
+ case key
419
+ when :escape
420
+ finish_insert_change_group
421
+ finish_dot_change_capture
422
+ clear_insert_completion
423
+ @editor.enter_normal_mode
424
+ @editor.echo("")
425
+ when :backspace
426
+ clear_insert_completion
427
+ y, x = @editor.current_buffer.backspace(@editor.current_window.cursor_y, @editor.current_window.cursor_x)
428
+ @editor.current_window.cursor_y = y
429
+ @editor.current_window.cursor_x = x
430
+ when :ctrl_n
431
+ insert_complete(+1)
432
+ when :ctrl_p
433
+ insert_complete(-1)
434
+ when :ctrl_i
435
+ clear_insert_completion
436
+ @editor.current_buffer.insert_char(@editor.current_window.cursor_y, @editor.current_window.cursor_x, "\t")
437
+ @editor.current_window.cursor_x += 1
438
+ when :enter
439
+ clear_insert_completion
440
+ y, x = @editor.current_buffer.insert_newline(@editor.current_window.cursor_y, @editor.current_window.cursor_x)
441
+ @editor.current_window.cursor_y = y
442
+ @editor.current_window.cursor_x = x
443
+ when :left
444
+ clear_insert_completion
445
+ @editor.current_window.move_left(@editor.current_buffer, 1)
446
+ when :right
447
+ clear_insert_completion
448
+ @editor.current_window.move_right(@editor.current_buffer, 1)
449
+ when :up
450
+ clear_insert_completion
451
+ @editor.current_window.move_up(@editor.current_buffer, 1)
452
+ when :down
453
+ clear_insert_completion
454
+ @editor.current_window.move_down(@editor.current_buffer, 1)
455
+ when :pageup, :pagedown
456
+ clear_insert_completion
457
+ invoke_page_key(key)
458
+ else
459
+ return unless key.is_a?(String)
460
+
461
+ clear_insert_completion
462
+ @editor.current_buffer.insert_char(@editor.current_window.cursor_y, @editor.current_window.cursor_x, key)
463
+ @editor.current_window.cursor_x += 1
464
+ end
465
+ end
466
+
467
+ def handle_visual_key(key)
468
+ if arrow_key?(key)
469
+ invoke_arrow(key)
470
+ return
471
+ end
472
+
473
+ if paging_key?(key)
474
+ invoke_page_key(key)
475
+ return
476
+ end
477
+
478
+ token = normalize_key_token(key)
479
+ return if token.nil?
480
+
481
+ case token
482
+ when "\e"
483
+ @register_pending = false
484
+ @visual_pending = nil
485
+ @editor.enter_normal_mode
486
+ when "v"
487
+ if @editor.mode == :visual_char
488
+ @editor.enter_normal_mode
489
+ else
490
+ @editor.enter_visual(:visual_char)
491
+ end
492
+ when "V"
493
+ if @editor.mode == :visual_line
494
+ @editor.enter_normal_mode
495
+ else
496
+ @editor.enter_visual(:visual_line)
497
+ end
498
+ when "<C-v>"
499
+ if @editor.mode == :visual_block
500
+ @editor.enter_normal_mode
501
+ else
502
+ @editor.enter_visual(:visual_block)
503
+ end
504
+ when "y"
505
+ @dispatcher.dispatch(@editor, CommandInvocation.new(id: "buffer.visual_yank"))
506
+ when "d"
507
+ @visual_pending = nil
508
+ @dispatcher.dispatch(@editor, CommandInvocation.new(id: "buffer.visual_delete"))
509
+ when "\""
510
+ start_register_pending
511
+ when "i", "a"
512
+ @visual_pending = token
513
+ else
514
+ if @register_pending
515
+ finish_register_pending(token)
516
+ return
517
+ end
518
+ if @visual_pending
519
+ if @editor.mode == :visual_block
520
+ @visual_pending = nil
521
+ @editor.echo_error("text object in Visual block not supported yet")
522
+ return
523
+ end
524
+ motion = "#{@visual_pending}#{token}"
525
+ @visual_pending = nil
526
+ inv = CommandInvocation.new(id: "buffer.visual_select_text_object", kwargs: { motion: motion })
527
+ @dispatcher.dispatch(@editor, inv)
528
+ else
529
+ handle_visual_motion_token(token)
530
+ end
531
+ end
532
+ @editor.pending_count = nil
533
+ @pending_keys = []
534
+ end
535
+
536
+ def handle_visual_motion_token(token)
537
+ id = {
538
+ "h" => "cursor.left",
539
+ "j" => "cursor.down",
540
+ "k" => "cursor.up",
541
+ "l" => "cursor.right",
542
+ "0" => "cursor.line_start",
543
+ "$" => "cursor.line_end",
544
+ "^" => "cursor.first_nonblank",
545
+ "w" => "cursor.word_forward",
546
+ "b" => "cursor.word_backward",
547
+ "e" => "cursor.word_end",
548
+ "G" => "cursor.buffer_end"
549
+ }[token]
550
+
551
+ if token == "g"
552
+ @pending_keys ||= []
553
+ @pending_keys << token
554
+ return
555
+ end
556
+
557
+ if @pending_keys == ["g"] && token == "g"
558
+ id = "cursor.buffer_start"
559
+ end
560
+
561
+ if id
562
+ count = @editor.pending_count || 1
563
+ @dispatcher.dispatch(@editor, CommandInvocation.new(id:, count: count))
564
+ else
565
+ @editor.echo_error("Unknown visual key: #{token}")
566
+ end
567
+ ensure
568
+ @pending_keys = [] unless token == "g"
569
+ end
570
+
571
+ def handle_command_line_key(key)
572
+ cmd = @editor.command_line
573
+ case key
574
+ when :escape
575
+ @editor.cancel_command_line
576
+ when :enter
577
+ line = cmd.text.dup
578
+ push_command_line_history(cmd.prefix, line)
579
+ handle_command_line_submit(cmd.prefix, line)
580
+ when :backspace
581
+ cmd.backspace
582
+ when :up
583
+ command_line_history_move(-1)
584
+ when :down
585
+ command_line_history_move(1)
586
+ when :left
587
+ cmd.move_left
588
+ when :right
589
+ cmd.move_right
590
+ else
591
+ if key == :ctrl_i
592
+ command_line_complete
593
+ elsif key.is_a?(String)
594
+ @cmdline_history_index = nil
595
+ cmd.insert(key)
596
+ end
597
+ end
598
+ end
599
+
600
+ def arrow_key?(key)
601
+ %i[left right up down].include?(key)
602
+ end
603
+
604
+ def paging_key?(key)
605
+ %i[pageup pagedown].include?(key)
606
+ end
607
+
608
+ def invoke_arrow(key)
609
+ id = {
610
+ left: "cursor.left",
611
+ right: "cursor.right",
612
+ up: "cursor.up",
613
+ down: "cursor.down"
614
+ }.fetch(key)
615
+ inv = CommandInvocation.new(id:, count: @editor.pending_count || 1)
616
+ @dispatcher.dispatch(@editor, inv)
617
+ @editor.pending_count = nil
618
+ @pending_keys = []
619
+ end
620
+
621
+ def invoke_page_key(key)
622
+ id = (key == :pageup ? "cursor.page_up" : "cursor.page_down")
623
+ inv = CommandInvocation.new(
624
+ id: id,
625
+ count: @editor.pending_count || 1,
626
+ kwargs: { page_lines: current_page_step_lines }
627
+ )
628
+ @dispatcher.dispatch(@editor, inv)
629
+ @editor.pending_count = nil
630
+ @pending_keys = []
631
+ end
632
+
633
+ def current_page_step_lines
634
+ height = @screen.current_window_view_height(@editor)
635
+ [height - 1, 1].max
636
+ rescue StandardError
637
+ 1
638
+ end
639
+
640
+ def digit_key?(key)
641
+ key.is_a?(String) && key.match?(/\A\d\z/)
642
+ end
643
+
644
+ def count_digit_allowed?(key)
645
+ return false unless @editor.mode == :normal
646
+ return true unless @editor.pending_count.nil?
647
+
648
+ key != "0"
649
+ end
650
+
651
+ def normalize_key_token(key)
652
+ case key
653
+ when String then key
654
+ when :escape then "\e"
655
+ when :ctrl_r then "<C-r>"
656
+ when :ctrl_v then "<C-v>"
657
+ when :ctrl_i then "<C-i>"
658
+ when :ctrl_o then "<C-o>"
659
+ when :ctrl_w then "<C-w>"
660
+ when :home then "<Home>"
661
+ when :end then "<End>"
662
+ when :pageup then "<PageUp>"
663
+ when :pagedown then "<PageDown>"
664
+ else nil
665
+ end
666
+ end
667
+
668
+ def dup_invocation(inv)
669
+ CommandInvocation.new(
670
+ id: inv.id,
671
+ argv: inv.argv.dup,
672
+ kwargs: inv.kwargs.dup,
673
+ count: inv.count,
674
+ bang: inv.bang,
675
+ raw_keys: inv.raw_keys&.dup
676
+ )
677
+ end
678
+
679
+ def handle_ctrl_c
680
+ case @editor.mode
681
+ when :insert
682
+ finish_insert_change_group
683
+ finish_dot_change_capture
684
+ clear_insert_completion
685
+ @editor.enter_normal_mode
686
+ @editor.echo("")
687
+ when :command_line
688
+ @editor.cancel_command_line
689
+ when :visual_char, :visual_line, :visual_block
690
+ @visual_pending = nil
691
+ @register_pending = false
692
+ @mark_pending = false
693
+ @jump_pending = nil
694
+ @editor.enter_normal_mode
695
+ else
696
+ @editor.pending_count = nil
697
+ @pending_keys = []
698
+ @operator_pending = nil
699
+ @replace_pending = nil
700
+ @register_pending = false
701
+ @mark_pending = false
702
+ @jump_pending = nil
703
+ @macro_record_pending = false
704
+ @macro_play_pending = false
705
+ @editor.clear_message
706
+ end
707
+ end
708
+
709
+ def finish_insert_change_group
710
+ @editor.current_buffer.end_change_group
711
+ end
712
+
713
+ def handle_command_line_submit(prefix, line)
714
+ case prefix
715
+ when ":"
716
+ verbose_log(2, "ex: #{line}")
717
+ @dispatcher.dispatch_ex(@editor, line)
718
+ when "/"
719
+ verbose_log(2, "search(/): #{line}")
720
+ submit_search(line, direction: :forward)
721
+ when "?"
722
+ verbose_log(2, "search(?): #{line}")
723
+ submit_search(line, direction: :backward)
724
+ else
725
+ @editor.echo_error("Unknown command-line prefix: #{prefix}")
726
+ @editor.enter_normal_mode
727
+ end
728
+ @cmdline_history_index = nil
729
+ end
730
+
731
+ def start_operator_pending(name)
732
+ @operator_pending = { name:, count: (@editor.pending_count || 1) }
733
+ @editor.pending_count = nil
734
+ @pending_keys = []
735
+ @editor.echo(name == :delete ? "d" : name.to_s)
736
+ end
737
+
738
+ def start_register_pending
739
+ @register_pending = true
740
+ @editor.echo('"')
741
+ end
742
+
743
+ def finish_register_pending(token)
744
+ @register_pending = false
745
+ if token.is_a?(String) && token.length == 1
746
+ @editor.set_active_register(token)
747
+ @editor.echo(%("#{token}))
748
+ else
749
+ @editor.echo_error("Invalid register")
750
+ end
751
+ end
752
+
753
+ def start_mark_pending
754
+ @mark_pending = true
755
+ @editor.echo("m")
756
+ end
757
+
758
+ def finish_mark_pending(token)
759
+ @mark_pending = false
760
+ if token == "\e"
761
+ @editor.clear_message
762
+ return
763
+ end
764
+ unless token.is_a?(String) && token.match?(/\A[A-Za-z]\z/)
765
+ @editor.echo_error("Invalid mark")
766
+ return
767
+ end
768
+
769
+ inv = CommandInvocation.new(id: "mark.set", kwargs: { mark: token })
770
+ @dispatcher.dispatch(@editor, inv)
771
+ end
772
+
773
+ def start_jump_pending(linewise:, repeat_token:)
774
+ @jump_pending = { linewise: linewise, repeat_token: repeat_token }
775
+ @editor.echo(repeat_token)
776
+ end
777
+
778
+ def finish_jump_pending(token)
779
+ pending = @jump_pending
780
+ @jump_pending = nil
781
+ return unless pending
782
+ if token == "\e"
783
+ @editor.clear_message
784
+ return
785
+ end
786
+
787
+ if token == pending[:repeat_token]
788
+ inv = CommandInvocation.new(id: "jump.older", kwargs: { linewise: pending[:linewise] })
789
+ @dispatcher.dispatch(@editor, inv)
790
+ return
791
+ end
792
+
793
+ unless token.is_a?(String) && token.match?(/\A[A-Za-z]\z/)
794
+ @editor.echo_error("Invalid mark")
795
+ return
796
+ end
797
+
798
+ inv = CommandInvocation.new(id: "mark.jump", kwargs: { mark: token, linewise: pending[:linewise] })
799
+ @dispatcher.dispatch(@editor, inv)
800
+ end
801
+
802
+ def start_macro_record_pending
803
+ @macro_record_pending = true
804
+ @editor.echo("q")
805
+ end
806
+
807
+ def finish_macro_record_pending(token)
808
+ @macro_record_pending = false
809
+ if token == "\e"
810
+ @editor.clear_message
811
+ return
812
+ end
813
+ unless token.is_a?(String) && token.match?(/\A[A-Za-z0-9]\z/)
814
+ @editor.echo_error("Invalid macro register")
815
+ return
816
+ end
817
+
818
+ unless @editor.start_macro_recording(token)
819
+ @editor.echo("Failed to start recording")
820
+ return
821
+ end
822
+ @skip_record_for_current_key = true
823
+ @editor.echo("recording @#{token}")
824
+ end
825
+
826
+ def stop_macro_recording
827
+ reg = @editor.macro_recording_name
828
+ @editor.stop_macro_recording
829
+ @editor.echo("recording @#{reg} stopped")
830
+ end
831
+
832
+ def start_macro_play_pending
833
+ @macro_play_pending = true
834
+ @editor.echo("@")
835
+ end
836
+
837
+ def finish_macro_play_pending(token)
838
+ @macro_play_pending = false
839
+ if token == "\e"
840
+ @editor.clear_message
841
+ return
842
+ end
843
+ name =
844
+ if token == "@"
845
+ @last_macro_name
846
+ elsif token.is_a?(String) && token.match?(/\A[A-Za-z0-9]\z/)
847
+ token
848
+ end
849
+ unless name
850
+ @editor.echo_error("Invalid macro register")
851
+ return
852
+ end
853
+
854
+ count = @editor.pending_count || 1
855
+ @editor.pending_count = nil
856
+ play_macro(name, count:)
857
+ end
858
+
859
+ def play_macro(name, count:)
860
+ reg = name.to_s.downcase
861
+ keys = @editor.macro_keys(reg)
862
+ if keys.nil? || keys.empty?
863
+ @editor.echo("Macro empty: #{reg}")
864
+ return
865
+ end
866
+
867
+ @macro_play_stack ||= []
868
+ if @macro_play_stack.include?(reg) || @macro_play_stack.length >= 20
869
+ @editor.echo("Macro recursion blocked: #{reg}")
870
+ return
871
+ end
872
+
873
+ @last_macro_name = reg
874
+ @macro_play_stack << reg
875
+ @suspend_macro_recording_depth = (@suspend_macro_recording_depth || 0) + 1
876
+ count.times do
877
+ keys.each { |k| handle_key(dup_macro_runtime_key(k)) }
878
+ end
879
+ @editor.echo("@#{reg}")
880
+ ensure
881
+ @suspend_macro_recording_depth = [(@suspend_macro_recording_depth || 1) - 1, 0].max
882
+ @macro_play_stack.pop if @macro_play_stack && !@macro_play_stack.empty?
883
+ end
884
+
885
+ def record_macro_key_if_needed(key)
886
+ return if @skip_record_for_current_key
887
+ return unless @editor.macro_recording?
888
+ return if (@suspend_macro_recording_depth || 0).positive?
889
+ return if (@dot_replay_depth || 0).positive?
890
+
891
+ @editor.record_macro_key(key)
892
+ end
893
+
894
+ def dup_macro_runtime_key(key)
895
+ case key
896
+ when String
897
+ key.dup
898
+ when Array
899
+ key.map { |v| v.is_a?(String) ? v.dup : v }
900
+ else
901
+ key
902
+ end
903
+ end
904
+
905
+ def handle_operator_pending_key(token)
906
+ op = @operator_pending
907
+ if %w[i a].include?(token) && !op[:motion_prefix]
908
+ @operator_pending[:motion_prefix] = token
909
+ @editor.echo("#{op[:name].to_s[0]}#{token}")
910
+ return
911
+ end
912
+
913
+ motion = [op[:motion_prefix], token].compact.join
914
+ @operator_pending = nil
915
+
916
+ if token == "\e"
917
+ @editor.clear_message
918
+ return
919
+ end
920
+
921
+ if op[:name] == :delete && motion == "d"
922
+ inv = CommandInvocation.new(id: "buffer.delete_line", count: op[:count])
923
+ @dispatcher.dispatch(@editor, inv)
924
+ record_last_change_keys(count_prefixed_keys(op[:count], ["d", "d"]))
925
+ return
926
+ end
927
+
928
+ if op[:name] == :delete
929
+ inv = CommandInvocation.new(id: "buffer.delete_motion", count: op[:count], kwargs: { motion: motion })
930
+ @dispatcher.dispatch(@editor, inv)
931
+ record_last_change_keys(count_prefixed_keys(op[:count], ["d", *motion.each_char.to_a]))
932
+ return
933
+ end
934
+
935
+ if op[:name] == :yank && motion == "y"
936
+ inv = CommandInvocation.new(id: "buffer.yank_line", count: op[:count])
937
+ @dispatcher.dispatch(@editor, inv)
938
+ return
939
+ end
940
+
941
+ if op[:name] == :yank
942
+ inv = CommandInvocation.new(id: "buffer.yank_motion", count: op[:count], kwargs: { motion: motion })
943
+ @dispatcher.dispatch(@editor, inv)
944
+ return
945
+ end
946
+
947
+ if op[:name] == :change && motion == "c"
948
+ inv = CommandInvocation.new(id: "buffer.change_line", count: op[:count])
949
+ @dispatcher.dispatch(@editor, inv)
950
+ begin_dot_change_capture(count_prefixed_keys(op[:count], ["c", "c"])) if @editor.mode == :insert
951
+ return
952
+ end
953
+
954
+ if op[:name] == :change
955
+ inv = CommandInvocation.new(id: "buffer.change_motion", count: op[:count], kwargs: { motion: motion })
956
+ @dispatcher.dispatch(@editor, inv)
957
+ begin_dot_change_capture(count_prefixed_keys(op[:count], ["c", *motion.each_char.to_a])) if @editor.mode == :insert
958
+ return
959
+ end
960
+
961
+ @editor.echo_error("Unknown operator")
962
+ end
963
+
964
+ def start_replace_pending
965
+ @replace_pending = { count: (@editor.pending_count || 1) }
966
+ @editor.pending_count = nil
967
+ @pending_keys = []
968
+ @editor.echo("r")
969
+ end
970
+
971
+ def handle_replace_pending_key(token)
972
+ pending = @replace_pending
973
+ @replace_pending = nil
974
+ if token == "\e"
975
+ @editor.clear_message
976
+ return
977
+ end
978
+
979
+ if token.is_a?(String) && !token.empty?
980
+ inv = CommandInvocation.new(id: "buffer.replace_char", argv: [token], count: pending[:count])
981
+ @dispatcher.dispatch(@editor, inv)
982
+ record_last_change_keys(count_prefixed_keys(pending[:count], ["r", token]))
983
+ else
984
+ @editor.echo("r expects one character")
985
+ end
986
+ end
987
+
988
+ def repeat_last_change
989
+ keys = @last_change_keys
990
+ if keys.nil? || keys.empty?
991
+ @editor.echo("No previous change")
992
+ return
993
+ end
994
+
995
+ @dot_replay_depth = (@dot_replay_depth || 0) + 1
996
+ keys.each { |k| handle_key(dup_macro_runtime_key(k)) }
997
+ @editor.echo(".")
998
+ ensure
999
+ @dot_replay_depth = [(@dot_replay_depth || 1) - 1, 0].max
1000
+ end
1001
+
1002
+ def maybe_record_simple_dot_change(invocation, matched_keys, count)
1003
+ return if (@dot_replay_depth || 0).positive?
1004
+
1005
+ case invocation.id
1006
+ when "buffer.delete_char", "buffer.paste_after", "buffer.paste_before"
1007
+ record_last_change_keys(count_prefixed_keys(count, matched_keys))
1008
+ when "mode.insert", "mode.append", "mode.append_line_end", "mode.insert_nonblank", "mode.open_below", "mode.open_above"
1009
+ begin_dot_change_capture(count_prefixed_keys(count, matched_keys)) if @editor.mode == :insert
1010
+ end
1011
+ end
1012
+
1013
+ def begin_dot_change_capture(prefix_keys)
1014
+ return if (@dot_replay_depth || 0).positive?
1015
+
1016
+ @dot_change_capture_keys = Array(prefix_keys).map { |k| dup_macro_runtime_key(k) }
1017
+ @dot_change_capture_active = true
1018
+ end
1019
+
1020
+ def append_dot_change_capture_key(key)
1021
+ return unless @dot_change_capture_active
1022
+ return if (@dot_replay_depth || 0).positive?
1023
+
1024
+ @dot_change_capture_keys ||= []
1025
+ @dot_change_capture_keys << dup_macro_runtime_key(key)
1026
+ end
1027
+
1028
+ def finish_dot_change_capture
1029
+ return unless @dot_change_capture_active
1030
+
1031
+ keys = Array(@dot_change_capture_keys)
1032
+ @dot_change_capture_active = false
1033
+ @dot_change_capture_keys = nil
1034
+ record_last_change_keys(keys)
1035
+ end
1036
+
1037
+ def record_last_change_keys(keys)
1038
+ return if (@dot_replay_depth || 0).positive?
1039
+
1040
+ @last_change_keys = Array(keys).map { |k| dup_macro_runtime_key(k) }
1041
+ end
1042
+
1043
+ def count_prefixed_keys(count, keys)
1044
+ c = count.to_i
1045
+ prefix = c > 1 ? c.to_s.each_char.to_a : []
1046
+ prefix + Array(keys)
1047
+ end
1048
+
1049
+ def start_find_pending(token)
1050
+ @find_pending = {
1051
+ direction: (token == "f" || token == "t") ? :forward : :backward,
1052
+ till: (token == "t" || token == "T"),
1053
+ count: (@editor.pending_count || 1)
1054
+ }
1055
+ @editor.pending_count = nil
1056
+ @pending_keys = []
1057
+ @editor.echo(token)
1058
+ end
1059
+
1060
+ def finish_find_pending(token)
1061
+ pending = @find_pending
1062
+ @find_pending = nil
1063
+ if token == "\e"
1064
+ @editor.clear_message
1065
+ return
1066
+ end
1067
+ unless token.is_a?(String) && !token.empty?
1068
+ @editor.echo("find expects one character")
1069
+ return
1070
+ end
1071
+
1072
+ moved = perform_find_on_line(
1073
+ char: token,
1074
+ direction: pending[:direction],
1075
+ till: pending[:till],
1076
+ count: pending[:count]
1077
+ )
1078
+ if moved
1079
+ @editor.set_last_find(char: token, direction: pending[:direction], till: pending[:till])
1080
+ else
1081
+ @editor.echo("Char not found: #{token}")
1082
+ end
1083
+ end
1084
+
1085
+ def repeat_last_find(reverse:)
1086
+ last = @editor.last_find
1087
+ unless last
1088
+ @editor.echo("No previous f/t")
1089
+ return
1090
+ end
1091
+
1092
+ direction =
1093
+ if reverse
1094
+ last[:direction] == :forward ? :backward : :forward
1095
+ else
1096
+ last[:direction]
1097
+ end
1098
+ count = @editor.pending_count || 1
1099
+ @editor.pending_count = nil
1100
+ @pending_keys = []
1101
+ moved = perform_find_on_line(char: last[:char], direction:, till: last[:till], count:)
1102
+ @editor.echo("Char not found: #{last[:char]}") unless moved
1103
+ end
1104
+
1105
+ def perform_find_on_line(char:, direction:, till:, count:)
1106
+ win = @editor.current_window
1107
+ buf = @editor.current_buffer
1108
+ line = buf.line_at(win.cursor_y)
1109
+ pos = win.cursor_x
1110
+ target = nil
1111
+
1112
+ count.times do
1113
+ idx =
1114
+ if direction == :forward
1115
+ line.index(char, pos + 1)
1116
+ else
1117
+ rindex_from(line, char, pos - 1)
1118
+ end
1119
+ return false if idx.nil?
1120
+
1121
+ target = idx
1122
+ pos = idx
1123
+ end
1124
+
1125
+ if till
1126
+ target =
1127
+ if direction == :forward
1128
+ RuVim::TextMetrics.previous_grapheme_char_index(line, target)
1129
+ else
1130
+ RuVim::TextMetrics.next_grapheme_char_index(line, target)
1131
+ end
1132
+ end
1133
+
1134
+ win.cursor_x = target
1135
+ win.clamp_to_buffer(buf)
1136
+ true
1137
+ end
1138
+
1139
+ def rindex_from(line, char, pos)
1140
+ return nil if pos.negative?
1141
+
1142
+ line.rindex(char, pos)
1143
+ end
1144
+
1145
+ def submit_search(line, direction:)
1146
+ inv = CommandInvocation.new(id: "__search_submit__", argv: [line], kwargs: { pattern: line, direction: direction })
1147
+ ctx = Context.new(editor: @editor, invocation: inv)
1148
+ GlobalCommands.instance.submit_search(ctx, pattern: line, direction: direction)
1149
+ @editor.enter_normal_mode
1150
+ rescue StandardError => e
1151
+ @editor.echo_error("Error: #{e.message}")
1152
+ @editor.enter_normal_mode
1153
+ end
1154
+
1155
+ def push_command_line_history(prefix, line)
1156
+ text = line.to_s
1157
+ return if text.empty?
1158
+
1159
+ hist = @cmdline_history[prefix]
1160
+ hist.delete(text)
1161
+ hist << text
1162
+ hist.shift while hist.length > 100
1163
+ @cmdline_history_index = nil
1164
+ end
1165
+
1166
+ def command_line_history_move(delta)
1167
+ cmd = @editor.command_line
1168
+ hist = @cmdline_history[cmd.prefix]
1169
+ return if hist.empty?
1170
+
1171
+ @cmdline_history_index =
1172
+ if @cmdline_history_index.nil?
1173
+ delta.negative? ? hist.length - 1 : hist.length
1174
+ else
1175
+ @cmdline_history_index + delta
1176
+ end
1177
+
1178
+ @cmdline_history_index = [[@cmdline_history_index, 0].max, hist.length].min
1179
+ if @cmdline_history_index == hist.length
1180
+ cmd.replace_text("")
1181
+ else
1182
+ cmd.replace_text(hist[@cmdline_history_index])
1183
+ end
1184
+ end
1185
+
1186
+ def command_line_complete
1187
+ cmd = @editor.command_line
1188
+ return unless cmd.prefix == ":"
1189
+
1190
+ ctx = ex_completion_context(cmd)
1191
+ return unless ctx
1192
+
1193
+ matches = ex_completion_candidates(ctx)
1194
+ case matches.length
1195
+ when 0
1196
+ @editor.echo("No completion")
1197
+ when 1
1198
+ cmd.replace_span(ctx[:token_start], ctx[:token_end], matches.first)
1199
+ else
1200
+ prefix = common_prefix(matches)
1201
+ cmd.replace_span(ctx[:token_start], ctx[:token_end], prefix) if prefix.length > ctx[:prefix].length
1202
+ @editor.echo(matches.join(" "))
1203
+ end
1204
+ end
1205
+
1206
+ def common_prefix(strings)
1207
+ return "" if strings.empty?
1208
+
1209
+ prefix = strings.first.dup
1210
+ strings[1..]&.each do |s|
1211
+ while !prefix.empty? && !s.start_with?(prefix)
1212
+ prefix = prefix[0...-1]
1213
+ end
1214
+ end
1215
+ prefix
1216
+ end
1217
+
1218
+ def clear_insert_completion
1219
+ @insert_completion = nil
1220
+ end
1221
+
1222
+ def insert_complete(direction)
1223
+ state = ensure_insert_completion_state
1224
+ return unless state
1225
+
1226
+ matches = state[:matches]
1227
+ if matches.empty?
1228
+ @editor.echo("No completion")
1229
+ return
1230
+ end
1231
+
1232
+ idx = state[:index]
1233
+ idx = idx.nil? ? (direction.positive? ? 0 : matches.length - 1) : (idx + direction) % matches.length
1234
+ replacement = matches[idx]
1235
+
1236
+ end_col = state[:current_end_col]
1237
+ start_col = state[:start_col]
1238
+ @editor.current_buffer.delete_span(state[:row], start_col, state[:row], end_col)
1239
+ _y, new_x = @editor.current_buffer.insert_text(state[:row], start_col, replacement)
1240
+ @editor.current_window.cursor_y = state[:row]
1241
+ @editor.current_window.cursor_x = new_x
1242
+ state[:index] = idx
1243
+ state[:current_end_col] = start_col + replacement.length
1244
+ @editor.echo(matches.length == 1 ? replacement : "#{replacement} (#{idx + 1}/#{matches.length})")
1245
+ rescue StandardError => e
1246
+ @editor.echo_error("Completion error: #{e.message}")
1247
+ clear_insert_completion
1248
+ end
1249
+
1250
+ def ensure_insert_completion_state
1251
+ row = @editor.current_window.cursor_y
1252
+ col = @editor.current_window.cursor_x
1253
+ line = @editor.current_buffer.line_at(row)
1254
+ prefix = line[0...col].to_s[/[[:alnum:]_]+\z/]
1255
+ return nil if prefix.nil? || prefix.empty?
1256
+
1257
+ start_col = col - prefix.length
1258
+ current_token = line[start_col...col].to_s
1259
+ state = @insert_completion
1260
+
1261
+ if state &&
1262
+ state[:row] == row &&
1263
+ state[:start_col] == start_col &&
1264
+ state[:prefix] == prefix &&
1265
+ col == state[:current_end_col]
1266
+ return state
1267
+ end
1268
+
1269
+ matches = collect_buffer_word_completions(prefix, current_word: current_token)
1270
+ @insert_completion = {
1271
+ row: row,
1272
+ start_col: start_col,
1273
+ prefix: prefix,
1274
+ matches: matches,
1275
+ index: nil,
1276
+ current_end_col: col
1277
+ }
1278
+ end
1279
+
1280
+ def collect_buffer_word_completions(prefix, current_word:)
1281
+ words = []
1282
+ seen = {}
1283
+ @editor.buffers.values.each do |buf|
1284
+ buf.lines.each do |line|
1285
+ line.scan(/[[:alnum:]_]+/) do |w|
1286
+ next unless w.start_with?(prefix)
1287
+ next if w == current_word
1288
+ next if seen[w]
1289
+
1290
+ seen[w] = true
1291
+ words << w
1292
+ end
1293
+ end
1294
+ end
1295
+ words.sort
1296
+ end
1297
+
1298
+ def ex_completion_context(cmd)
1299
+ text = cmd.text
1300
+ cursor = cmd.cursor
1301
+ token_start = token_start_index(text, cursor)
1302
+ token_end = token_end_index(text, cursor)
1303
+ prefix = text[token_start...cursor].to_s
1304
+ before = text[0...token_start].to_s
1305
+ argv_before = before.split(/\s+/).reject(&:empty?)
1306
+
1307
+ if argv_before.empty?
1308
+ {
1309
+ kind: :command,
1310
+ token_start: token_start,
1311
+ token_end: token_end,
1312
+ prefix: prefix
1313
+ }
1314
+ else
1315
+ {
1316
+ kind: :arg,
1317
+ command: argv_before.first,
1318
+ arg_index: argv_before.length - 1,
1319
+ token_start: token_start,
1320
+ token_end: token_end,
1321
+ prefix: prefix
1322
+ }
1323
+ end
1324
+ end
1325
+
1326
+ def ex_completion_candidates(ctx)
1327
+ case ctx[:kind]
1328
+ when :command
1329
+ ExCommandRegistry.instance.all.flat_map { |spec| [spec.name, *spec.aliases] }.uniq.sort.select { |n| n.start_with?(ctx[:prefix]) }
1330
+ when :arg
1331
+ ex_arg_completion_candidates(ctx[:command], ctx[:arg_index], ctx[:prefix])
1332
+ else
1333
+ []
1334
+ end
1335
+ end
1336
+
1337
+ def ex_arg_completion_candidates(command_name, arg_index, prefix)
1338
+ cmd = command_name.to_s
1339
+ return [] unless arg_index.zero?
1340
+
1341
+ if %w[e edit w write tabnew].include?(cmd)
1342
+ return path_completion_candidates(prefix)
1343
+ end
1344
+
1345
+ if %w[buffer b].include?(cmd)
1346
+ return buffer_completion_candidates(prefix)
1347
+ end
1348
+
1349
+ if %w[set setlocal setglobal].include?(cmd)
1350
+ return option_completion_candidates(prefix)
1351
+ end
1352
+
1353
+ []
1354
+ end
1355
+
1356
+ def path_completion_candidates(prefix)
1357
+ input = prefix.to_s
1358
+ base_dir =
1359
+ if input.empty?
1360
+ "."
1361
+ elsif input.end_with?("/")
1362
+ input
1363
+ else
1364
+ File.dirname(input)
1365
+ end
1366
+ base_dir = "." if base_dir == "."
1367
+ partial = input.end_with?("/") ? "" : File.basename(input)
1368
+ pattern = input.empty? ? "*" : File.join(base_dir, "#{partial}*")
1369
+ Dir.glob(pattern, File::FNM_DOTMATCH).sort.filter_map do |p|
1370
+ next if [".", ".."].include?(File.basename(p))
1371
+ next unless p.start_with?(input) || input.empty?
1372
+ File.directory?(p) ? "#{p}/" : p
1373
+ end
1374
+ rescue StandardError
1375
+ []
1376
+ end
1377
+
1378
+ def buffer_completion_candidates(prefix)
1379
+ pfx = prefix.to_s
1380
+ items = @editor.buffers.values.flat_map do |b|
1381
+ path = b.path.to_s
1382
+ base = path.empty? ? nil : File.basename(path)
1383
+ [b.id.to_s, path, base].compact
1384
+ end.uniq.sort
1385
+ items.select { |s| s.start_with?(pfx) }
1386
+ end
1387
+
1388
+ def option_completion_candidates(prefix)
1389
+ pfx = prefix.to_s
1390
+ names = RuVim::Editor::OPTION_DEFS.keys
1391
+ tokens = names + names.map { |n| "no#{n}" } + names.map { |n| "inv#{n}" } + names.map { |n| "#{n}?" }
1392
+ tokens.uniq.sort.select { |s| s.start_with?(pfx) }
1393
+ end
1394
+
1395
+ def token_start_index(text, cursor)
1396
+ i = [[cursor, 0].max, text.length].min
1397
+ i -= 1 while i.positive? && !whitespace_char?(text[i - 1])
1398
+ i
1399
+ end
1400
+
1401
+ def token_end_index(text, cursor)
1402
+ i = [[cursor, 0].max, text.length].min
1403
+ i += 1 while i < text.length && !whitespace_char?(text[i])
1404
+ i
1405
+ end
1406
+
1407
+ def whitespace_char?(ch)
1408
+ ch && ch.match?(/\s/)
1409
+ end
1410
+
1411
+ def install_signal_handlers
1412
+ Signal.trap("WINCH") do
1413
+ @screen.invalidate_cache! if @screen.respond_to?(:invalidate_cache!)
1414
+ @needs_redraw = true
1415
+ notify_signal_wakeup
1416
+ end
1417
+ rescue ArgumentError
1418
+ nil
1419
+ end
1420
+
1421
+ def init_config_loader!
1422
+ @config_loader = ConfigLoader.new(
1423
+ command_registry: CommandRegistry.instance,
1424
+ ex_registry: ExCommandRegistry.instance,
1425
+ keymaps: @keymaps,
1426
+ command_host: GlobalCommands.instance
1427
+ )
1428
+ end
1429
+
1430
+ def load_user_config!
1431
+ return if @clean_mode || @restricted_mode
1432
+ return if @skip_user_config
1433
+
1434
+ if @config_path
1435
+ @config_loader.load_file(@config_path)
1436
+ else
1437
+ @config_loader.load_default!
1438
+ end
1439
+ rescue StandardError => e
1440
+ @editor.echo_error("config error: #{e.message}")
1441
+ end
1442
+
1443
+ def load_current_ftplugin!
1444
+ return if @clean_mode || @restricted_mode
1445
+ return unless @config_loader
1446
+
1447
+ @config_loader.load_ftplugin!(@editor, @editor.current_buffer)
1448
+ rescue StandardError => e
1449
+ @editor.echo_error("ftplugin error: #{e.message}")
1450
+ end
1451
+
1452
+ def run_startup_action!(action, log_prefix: "startup")
1453
+ case action[:type]
1454
+ when :ex
1455
+ verbose_log(2, "#{log_prefix} ex: #{action[:value]}")
1456
+ @dispatcher.dispatch_ex(@editor, action[:value].to_s)
1457
+ when :line
1458
+ verbose_log(2, "#{log_prefix} line: #{action[:value]}")
1459
+ move_cursor_to_line(action[:value].to_i)
1460
+ when :line_end
1461
+ verbose_log(2, "#{log_prefix} line_end")
1462
+ move_cursor_to_line(@editor.current_buffer.line_count)
1463
+ end
1464
+ end
1465
+
1466
+ def verbose_log(level, message)
1467
+ return if @verbose_level.to_i < level.to_i
1468
+ return unless @verbose_io
1469
+
1470
+ @verbose_io.puts("[ruvim:v#{@verbose_level}] #{message}")
1471
+ @verbose_io.flush if @verbose_io.respond_to?(:flush)
1472
+ rescue StandardError
1473
+ nil
1474
+ end
1475
+
1476
+ def startup_mark(label)
1477
+ return unless @startup_time_path
1478
+
1479
+ @startup_timeline << [label.to_s, monotonic_now]
1480
+ end
1481
+
1482
+ def write_startuptime_log!
1483
+ return unless @startup_time_path
1484
+
1485
+ prev = @startup_time_origin
1486
+ lines = @startup_timeline.map do |label, t|
1487
+ total_ms = ((t - @startup_time_origin) * 1000.0)
1488
+ delta_ms = ((t - prev) * 1000.0)
1489
+ prev = t
1490
+ format("%9.3f %9.3f %s", total_ms, delta_ms, label)
1491
+ end
1492
+ File.write(@startup_time_path, lines.join("\n") + "\n")
1493
+ rescue StandardError => e
1494
+ verbose_log(1, "startuptime write error: #{e.message}")
1495
+ end
1496
+
1497
+ def monotonic_now
1498
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
1499
+ rescue StandardError
1500
+ Time.now.to_f
1501
+ end
1502
+
1503
+ def apply_startup_readonly!
1504
+ buf = @editor.current_buffer
1505
+ return unless buf&.file_buffer?
1506
+
1507
+ buf.readonly = true
1508
+ @editor.echo("readonly: #{buf.display_name}")
1509
+ end
1510
+
1511
+ def apply_startup_nomodifiable!
1512
+ buf = @editor.current_buffer
1513
+ return unless buf&.file_buffer?
1514
+
1515
+ buf.modifiable = false
1516
+ buf.readonly = true
1517
+ @editor.echo("nomodifiable: #{buf.display_name}")
1518
+ end
1519
+
1520
+ def apply_startup_compat_mode_messages!
1521
+ if @startup_diff_mode
1522
+ verbose_log(1, "startup: -d requested (diff mode placeholder)")
1523
+ @editor.echo("diff mode (-d) is not implemented yet")
1524
+ end
1525
+
1526
+ if @startup_quickfix_errorfile
1527
+ verbose_log(1, "startup: -q #{@startup_quickfix_errorfile} requested (quickfix placeholder)")
1528
+ @editor.echo("quickfix startup (-q #{@startup_quickfix_errorfile}) is not implemented yet")
1529
+ end
1530
+
1531
+ if @startup_session_file
1532
+ verbose_log(1, "startup: -S #{@startup_session_file} requested (session placeholder)")
1533
+ @editor.echo("session startup (-S #{@startup_session_file}) is not implemented yet")
1534
+ end
1535
+ end
1536
+
1537
+ def open_startup_paths!(paths)
1538
+ list = Array(paths).compact
1539
+ return if list.empty?
1540
+
1541
+ first, *rest = list
1542
+ @editor.open_path(first)
1543
+ apply_startup_readonly! if @startup_readonly
1544
+ apply_startup_nomodifiable! if @startup_nomodifiable
1545
+
1546
+ case @startup_open_layout
1547
+ when :horizontal
1548
+ rest.each { |p| open_path_in_split!(p, layout: :horizontal) }
1549
+ when :vertical
1550
+ rest.each { |p| open_path_in_split!(p, layout: :vertical) }
1551
+ when :tab
1552
+ rest.each { |p| open_path_in_tab!(p) }
1553
+ else
1554
+ # No multi-file layout mode yet; ignore extras if called directly.
1555
+ end
1556
+ end
1557
+
1558
+ def open_path_in_split!(path, layout:)
1559
+ @editor.split_current_window(layout:)
1560
+ buf = @editor.add_buffer_from_file(path)
1561
+ @editor.switch_to_buffer(buf.id)
1562
+ apply_startup_readonly! if @startup_readonly
1563
+ apply_startup_nomodifiable! if @startup_nomodifiable
1564
+ end
1565
+
1566
+ def open_path_in_tab!(path)
1567
+ @editor.tabnew(path:)
1568
+ apply_startup_readonly! if @startup_readonly
1569
+ apply_startup_nomodifiable! if @startup_nomodifiable
1570
+ end
1571
+
1572
+ def move_cursor_to_line(line_number)
1573
+ win = @editor.current_window
1574
+ buf = @editor.current_buffer
1575
+ return unless win && buf
1576
+
1577
+ target = [[line_number.to_i - 1, 0].max, buf.line_count - 1].min
1578
+ win.cursor_y = target
1579
+ win.clamp_to_buffer(buf)
1580
+ end
1581
+
1582
+ def notify_signal_wakeup
1583
+ @signal_w.write_nonblock(".")
1584
+ rescue IO::WaitWritable, Errno::EPIPE
1585
+ nil
1586
+ end
1587
+
1588
+ def register_internal_unless(registry, id, **spec)
1589
+ return if registry.registered?(id)
1590
+
1591
+ registry.register(id, **spec)
1592
+ end
1593
+
1594
+ def register_ex_unless(registry, name, **spec)
1595
+ return if registry.registered?(name)
1596
+
1597
+ registry.register(name, **spec)
1598
+ end
1599
+ end
1600
+ end