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
@@ -0,0 +1,1889 @@
1
+ module RuVim
2
+ class GlobalCommands
3
+ include Singleton
4
+
5
+ def call(spec_call, ctx, argv: [], kwargs: {}, bang: false, count: 1)
6
+ case spec_call
7
+ when Symbol, String
8
+ public_send(spec_call.to_sym, ctx, argv: argv, kwargs: kwargs, bang: bang, count: count)
9
+ else
10
+ spec_call.call(ctx, argv: argv, kwargs: kwargs, bang: bang, count: count)
11
+ end
12
+ end
13
+
14
+ def cursor_left(ctx, count:, **)
15
+ ctx.window.move_left(ctx.buffer, count)
16
+ end
17
+
18
+ def cursor_right(ctx, count:, **)
19
+ ctx.window.move_right(ctx.buffer, count)
20
+ end
21
+
22
+ def cursor_up(ctx, count:, **)
23
+ ctx.window.move_up(ctx.buffer, count)
24
+ end
25
+
26
+ def cursor_down(ctx, count:, **)
27
+ ctx.window.move_down(ctx.buffer, count)
28
+ end
29
+
30
+ def cursor_page_up(ctx, kwargs:, count:, **)
31
+ page_lines = [(kwargs[:page_lines] || kwargs["page_lines"] || 1).to_i, 1].max
32
+ ctx.window.move_up(ctx.buffer, page_lines * [count.to_i, 1].max)
33
+ end
34
+
35
+ def cursor_page_down(ctx, kwargs:, count:, **)
36
+ page_lines = [(kwargs[:page_lines] || kwargs["page_lines"] || 1).to_i, 1].max
37
+ ctx.window.move_down(ctx.buffer, page_lines * [count.to_i, 1].max)
38
+ end
39
+
40
+ def cursor_line_start(ctx, **)
41
+ ctx.window.cursor_x = 0
42
+ ctx.window.clamp_to_buffer(ctx.buffer)
43
+ end
44
+
45
+ def cursor_line_end(ctx, **)
46
+ ctx.window.cursor_x = ctx.buffer.line_length(ctx.window.cursor_y)
47
+ ctx.window.clamp_to_buffer(ctx.buffer)
48
+ end
49
+
50
+ def cursor_first_nonblank(ctx, **)
51
+ line = ctx.buffer.line_at(ctx.window.cursor_y)
52
+ idx = line.index(/\S/) || 0
53
+ ctx.window.cursor_x = idx
54
+ ctx.window.clamp_to_buffer(ctx.buffer)
55
+ end
56
+
57
+ def cursor_buffer_start(ctx, count:, **)
58
+ record_jump(ctx)
59
+ target_row = [count.to_i - 1, 0].max
60
+ target_row = [target_row, ctx.buffer.line_count - 1].min
61
+ ctx.window.cursor_y = target_row
62
+ cursor_first_nonblank(ctx)
63
+ end
64
+
65
+ def cursor_buffer_end(ctx, count:, **)
66
+ record_jump(ctx)
67
+ if count && count > 1
68
+ target_row = [count - 1, ctx.buffer.line_count - 1].min
69
+ else
70
+ target_row = ctx.buffer.line_count - 1
71
+ end
72
+ ctx.window.cursor_y = target_row
73
+ cursor_first_nonblank(ctx)
74
+ end
75
+
76
+ def cursor_word_forward(ctx, count:, **)
77
+ move_cursor_word(ctx, count:, kind: :forward_start)
78
+ end
79
+
80
+ def cursor_word_backward(ctx, count:, **)
81
+ move_cursor_word(ctx, count:, kind: :backward_start)
82
+ end
83
+
84
+ def cursor_word_end(ctx, count:, **)
85
+ move_cursor_word(ctx, count:, kind: :forward_end)
86
+ end
87
+
88
+ def cursor_match_bracket(ctx, **)
89
+ line = ctx.buffer.line_at(ctx.window.cursor_y)
90
+ ch = line[ctx.window.cursor_x]
91
+ unless ch
92
+ ctx.editor.echo_error("No bracket under cursor")
93
+ return
94
+ end
95
+
96
+ pair_map = {
97
+ "(" => [")", :forward],
98
+ "[" => ["]", :forward],
99
+ "{" => ["}", :forward],
100
+ ")" => ["(", :backward],
101
+ "]" => ["[", :backward],
102
+ "}" => ["{", :backward]
103
+ }
104
+ pair = pair_map[ch]
105
+ unless pair
106
+ ctx.editor.echo_error("No bracket under cursor")
107
+ return
108
+ end
109
+
110
+ target_char, direction = pair
111
+ record_jump(ctx)
112
+ loc = find_matching_bracket(ctx.buffer, ctx.window.cursor_y, ctx.window.cursor_x, ch, target_char, direction)
113
+ if loc
114
+ ctx.window.cursor_y = loc[:row]
115
+ ctx.window.cursor_x = loc[:col]
116
+ ctx.window.clamp_to_buffer(ctx.buffer)
117
+ else
118
+ ctx.editor.echo_error("Match not found")
119
+ end
120
+ end
121
+
122
+ def enter_insert_mode(ctx, **)
123
+ materialize_intro_buffer_if_needed(ctx)
124
+ ctx.buffer.begin_change_group
125
+ ctx.editor.enter_insert_mode
126
+ ctx.editor.echo("-- INSERT --")
127
+ end
128
+
129
+ def append_mode(ctx, **)
130
+ x = ctx.window.cursor_x
131
+ len = ctx.buffer.line_length(ctx.window.cursor_y)
132
+ ctx.window.cursor_x = [x + 1, len].min
133
+ enter_insert_mode(ctx)
134
+ end
135
+
136
+ def append_line_end_mode(ctx, **)
137
+ ctx.window.cursor_x = ctx.buffer.line_length(ctx.window.cursor_y)
138
+ enter_insert_mode(ctx)
139
+ end
140
+
141
+ def insert_line_start_nonblank_mode(ctx, **)
142
+ cursor_first_nonblank(ctx)
143
+ enter_insert_mode(ctx)
144
+ end
145
+
146
+ def open_line_below(ctx, **)
147
+ materialize_intro_buffer_if_needed(ctx)
148
+ y = ctx.window.cursor_y
149
+ x = ctx.buffer.line_length(y)
150
+ ctx.buffer.begin_change_group
151
+ new_y, new_x = ctx.buffer.insert_newline(y, x)
152
+ ctx.window.cursor_y = new_y
153
+ ctx.window.cursor_x = new_x
154
+ ctx.editor.enter_insert_mode
155
+ ctx.editor.echo("-- INSERT --")
156
+ end
157
+
158
+ def open_line_above(ctx, **)
159
+ materialize_intro_buffer_if_needed(ctx)
160
+ y = ctx.window.cursor_y
161
+ ctx.buffer.begin_change_group
162
+ ctx.buffer.insert_newline(y, 0)
163
+ ctx.window.cursor_x = 0
164
+ ctx.editor.enter_insert_mode
165
+ ctx.editor.echo("-- INSERT --")
166
+ end
167
+
168
+ def enter_visual_char_mode(ctx, **)
169
+ ctx.editor.enter_visual(:visual_char)
170
+ ctx.editor.echo("-- VISUAL --")
171
+ end
172
+
173
+ def enter_visual_line_mode(ctx, **)
174
+ ctx.editor.enter_visual(:visual_line)
175
+ ctx.editor.echo("-- VISUAL LINE --")
176
+ end
177
+
178
+ def enter_visual_block_mode(ctx, **)
179
+ ctx.editor.enter_visual(:visual_block)
180
+ ctx.editor.echo("-- VISUAL BLOCK --")
181
+ end
182
+
183
+ def window_split(ctx, **)
184
+ ctx.editor.split_current_window(layout: :horizontal)
185
+ ctx.editor.echo("split")
186
+ end
187
+
188
+ def window_vsplit(ctx, **)
189
+ ctx.editor.split_current_window(layout: :vertical)
190
+ ctx.editor.echo("vsplit")
191
+ end
192
+
193
+ def window_focus_next(ctx, **)
194
+ ctx.editor.focus_next_window
195
+ end
196
+
197
+ def window_focus_left(ctx, **)
198
+ ctx.editor.focus_window_direction(:left)
199
+ end
200
+
201
+ def window_focus_right(ctx, **)
202
+ ctx.editor.focus_window_direction(:right)
203
+ end
204
+
205
+ def window_focus_up(ctx, **)
206
+ ctx.editor.focus_window_direction(:up)
207
+ end
208
+
209
+ def window_focus_down(ctx, **)
210
+ ctx.editor.focus_window_direction(:down)
211
+ end
212
+
213
+ def tab_new(ctx, argv:, **)
214
+ path = argv[0]
215
+ if ctx.buffer.modified?
216
+ ctx.editor.echo_error("Unsaved changes (use :w or :q!)")
217
+ return
218
+ end
219
+ tab = ctx.editor.tabnew(path: path)
220
+ if path && !path.empty?
221
+ b = ctx.editor.current_buffer
222
+ ctx.editor.echo("tab #{ctx.editor.current_tabpage_number}/#{ctx.editor.tabpage_count}: #{b.path || '[No Name]'}")
223
+ else
224
+ ctx.editor.echo("tab #{ctx.editor.current_tabpage_number}/#{ctx.editor.tabpage_count}")
225
+ end
226
+ tab
227
+ end
228
+
229
+ def tab_next(ctx, count:, **)
230
+ ctx.editor.tabnext(count)
231
+ ctx.editor.echo("tab #{ctx.editor.current_tabpage_number}/#{ctx.editor.tabpage_count}")
232
+ end
233
+
234
+ def tab_prev(ctx, count:, **)
235
+ ctx.editor.tabprev(count)
236
+ ctx.editor.echo("tab #{ctx.editor.current_tabpage_number}/#{ctx.editor.tabpage_count}")
237
+ end
238
+
239
+ def enter_command_line_mode(ctx, **)
240
+ ctx.editor.enter_command_line_mode(":")
241
+ ctx.editor.clear_message
242
+ end
243
+
244
+ def enter_search_forward_mode(ctx, **)
245
+ ctx.editor.enter_command_line_mode("/")
246
+ ctx.editor.clear_message
247
+ end
248
+
249
+ def enter_search_backward_mode(ctx, **)
250
+ ctx.editor.enter_command_line_mode("?")
251
+ ctx.editor.clear_message
252
+ end
253
+
254
+ def delete_char(ctx, count:, **)
255
+ materialize_intro_buffer_if_needed(ctx)
256
+ ctx.buffer.begin_change_group
257
+ deleted = +""
258
+ count.times do
259
+ chunk = char_at_cursor_for_delete(ctx.buffer, ctx.window.cursor_y, ctx.window.cursor_x)
260
+ ok = ctx.buffer.delete_char(ctx.window.cursor_y, ctx.window.cursor_x)
261
+ break unless ok
262
+ deleted << chunk.to_s
263
+ end
264
+ ctx.buffer.end_change_group
265
+ store_delete_register(ctx, text: deleted, type: :charwise) unless deleted.empty?
266
+ ctx.window.clamp_to_buffer(ctx.buffer)
267
+ end
268
+
269
+ def delete_line(ctx, count:, **)
270
+ materialize_intro_buffer_if_needed(ctx)
271
+ ctx.buffer.begin_change_group
272
+ deleted_lines = []
273
+ count.times { deleted_lines << ctx.buffer.delete_line(ctx.window.cursor_y) }
274
+ ctx.buffer.end_change_group
275
+ store_delete_register(ctx, text: deleted_lines.join("\n") + "\n", type: :linewise)
276
+ ctx.window.clamp_to_buffer(ctx.buffer)
277
+ end
278
+
279
+ def delete_motion(ctx, count:, kwargs:, **)
280
+ materialize_intro_buffer_if_needed(ctx)
281
+ motion = (kwargs[:motion] || kwargs["motion"]).to_s
282
+ handled =
283
+ case motion
284
+ when "h" then delete_chars_left(ctx, count)
285
+ when "l" then delete_chars_right(ctx, count)
286
+ when "j" then delete_lines_down(ctx, count)
287
+ when "k" then delete_lines_up(ctx, count)
288
+ when "$" then delete_to_end_of_line(ctx)
289
+ when "w" then delete_word_forward(ctx, count)
290
+ when "iw" then delete_text_object_word(ctx, around: false)
291
+ when "aw" then delete_text_object_word(ctx, around: true)
292
+ else
293
+ text_object_motion?(motion) ? delete_text_object(ctx, motion) : false
294
+ end
295
+ ctx.editor.echo("Unsupported motion for d: #{motion}") unless handled
296
+ handled
297
+ end
298
+
299
+ def change_motion(ctx, count:, kwargs:, **)
300
+ materialize_intro_buffer_if_needed(ctx)
301
+ handled = delete_motion(ctx, count:, kwargs:)
302
+ return unless handled
303
+
304
+ enter_insert_mode(ctx)
305
+ end
306
+
307
+ def change_line(ctx, count:, **)
308
+ materialize_intro_buffer_if_needed(ctx)
309
+ delete_line(ctx, count:)
310
+ enter_insert_mode(ctx)
311
+ end
312
+
313
+ def buffer_undo(ctx, **)
314
+ if ctx.buffer.undo!
315
+ ctx.window.clamp_to_buffer(ctx.buffer)
316
+ ctx.editor.echo("Undo")
317
+ else
318
+ ctx.editor.echo("Already at oldest change")
319
+ end
320
+ end
321
+
322
+ def buffer_redo(ctx, **)
323
+ if ctx.buffer.redo!
324
+ ctx.window.clamp_to_buffer(ctx.buffer)
325
+ ctx.editor.echo("Redo")
326
+ else
327
+ ctx.editor.echo("Already at newest change")
328
+ end
329
+ end
330
+
331
+ def search_next(ctx, count:, **)
332
+ record_jump(ctx)
333
+ repeat_search(ctx, forward: true, count:)
334
+ end
335
+
336
+ def search_prev(ctx, count:, **)
337
+ record_jump(ctx)
338
+ repeat_search(ctx, forward: false, count:)
339
+ end
340
+
341
+ def search_word_forward(ctx, **)
342
+ record_jump(ctx)
343
+ search_current_word(ctx, exact: true, direction: :forward)
344
+ end
345
+
346
+ def search_word_backward(ctx, **)
347
+ record_jump(ctx)
348
+ search_current_word(ctx, exact: true, direction: :backward)
349
+ end
350
+
351
+ def search_word_forward_partial(ctx, **)
352
+ record_jump(ctx)
353
+ search_current_word(ctx, exact: false, direction: :forward)
354
+ end
355
+
356
+ def search_word_backward_partial(ctx, **)
357
+ record_jump(ctx)
358
+ search_current_word(ctx, exact: false, direction: :backward)
359
+ end
360
+
361
+ def mark_set(ctx, kwargs:, **)
362
+ mark = (kwargs[:mark] || kwargs["mark"]).to_s
363
+ raise RuVim::CommandError, "Invalid mark" unless ctx.editor.set_mark(mark)
364
+
365
+ ctx.editor.echo("mark #{mark}")
366
+ end
367
+
368
+ def mark_jump(ctx, kwargs:, **)
369
+ mark = (kwargs[:mark] || kwargs["mark"]).to_s
370
+ linewise = !!(kwargs[:linewise] || kwargs["linewise"])
371
+ record_jump(ctx)
372
+ loc = ctx.editor.jump_to_mark(mark, linewise:)
373
+ if loc
374
+ ctx.editor.echo("#{linewise ? "'" : '`'}#{mark}")
375
+ else
376
+ ctx.editor.echo("Mark not set: #{mark}")
377
+ end
378
+ end
379
+
380
+ def jump_older(ctx, kwargs: {}, **)
381
+ linewise = !!(kwargs[:linewise] || kwargs["linewise"])
382
+ loc = ctx.editor.jump_older(linewise:)
383
+ ctx.editor.echo(loc ? (linewise ? "''" : "``") : "Jump list empty")
384
+ end
385
+
386
+ def jump_newer(ctx, kwargs: {}, **)
387
+ linewise = !!(kwargs[:linewise] || kwargs["linewise"])
388
+ loc = ctx.editor.jump_newer(linewise:)
389
+ ctx.editor.echo(loc ? "<C-i>" : "At newest jump")
390
+ end
391
+
392
+ def replace_char(ctx, argv:, count:, **)
393
+ materialize_intro_buffer_if_needed(ctx)
394
+ ch = argv[0].to_s
395
+ raise RuVim::CommandError, "replace requires a character" if ch.empty?
396
+
397
+ y = ctx.window.cursor_y
398
+ x = ctx.window.cursor_x
399
+ line = ctx.buffer.line_at(y)
400
+ return if x >= line.length
401
+
402
+ ctx.buffer.begin_change_group
403
+ count.times do |i|
404
+ cx = x + i
405
+ break if cx >= ctx.buffer.line_length(y)
406
+ ctx.buffer.delete_span(y, cx, y, cx + 1)
407
+ ctx.buffer.insert_char(y, cx, ch[0])
408
+ end
409
+ ctx.buffer.end_change_group
410
+ ctx.window.clamp_to_buffer(ctx.buffer)
411
+ end
412
+
413
+ def yank_line(ctx, count:, **)
414
+ start = ctx.window.cursor_y
415
+ text = ctx.buffer.line_block_text(start, count)
416
+ store_yank_register(ctx, text:, type: :linewise)
417
+ ctx.editor.echo("#{count} line(s) yanked")
418
+ end
419
+
420
+ def yank_motion(ctx, count:, kwargs:, **)
421
+ motion = (kwargs[:motion] || kwargs["motion"]).to_s
422
+ case motion
423
+ when "w"
424
+ y = ctx.window.cursor_y
425
+ x = ctx.window.cursor_x
426
+ target = advance_word_forward(ctx.buffer, y, x, count)
427
+ target ||= { row: y, col: x }
428
+ text = ctx.buffer.span_text(y, x, target[:row], target[:col])
429
+ store_yank_register(ctx, text:, type: :charwise)
430
+ ctx.editor.echo("yanked")
431
+ when "iw"
432
+ yank_text_object_word(ctx, around: false)
433
+ when "aw"
434
+ yank_text_object_word(ctx, around: true)
435
+ when "y"
436
+ yank_line(ctx, count:)
437
+ else
438
+ if text_object_motion?(motion)
439
+ yank_text_object(ctx, motion)
440
+ else
441
+ ctx.editor.echo("Unsupported motion for y: #{motion}")
442
+ end
443
+ end
444
+ end
445
+
446
+ def paste_after(ctx, count:, **)
447
+ materialize_intro_buffer_if_needed(ctx)
448
+ paste_register(ctx, before: false, count:)
449
+ end
450
+
451
+ def paste_before(ctx, count:, **)
452
+ materialize_intro_buffer_if_needed(ctx)
453
+ paste_register(ctx, before: true, count:)
454
+ end
455
+
456
+ def visual_yank(ctx, **)
457
+ sel = ctx.editor.visual_selection
458
+ return unless sel
459
+
460
+ if sel[:mode] == :linewise
461
+ count = sel[:end_row] - sel[:start_row] + 1
462
+ text = ctx.buffer.line_block_text(sel[:start_row], count)
463
+ store_yank_register(ctx, text:, type: :linewise)
464
+ elsif sel[:mode] == :blockwise
465
+ text = blockwise_selection_text(ctx.buffer, sel)
466
+ # Blockwise register/paste semantics are not implemented yet; store text payload.
467
+ store_yank_register(ctx, text:, type: :charwise)
468
+ else
469
+ text = ctx.buffer.span_text(sel[:start_row], sel[:start_col], sel[:end_row], sel[:end_col])
470
+ store_yank_register(ctx, text:, type: :charwise)
471
+ end
472
+ ctx.editor.enter_normal_mode
473
+ ctx.editor.echo("yanked")
474
+ end
475
+
476
+ def visual_delete(ctx, **)
477
+ materialize_intro_buffer_if_needed(ctx)
478
+ sel = ctx.editor.visual_selection
479
+ return unless sel
480
+
481
+ if sel[:mode] == :linewise
482
+ count = sel[:end_row] - sel[:start_row] + 1
483
+ text = ctx.buffer.line_block_text(sel[:start_row], count)
484
+ ctx.buffer.begin_change_group
485
+ count.times { ctx.buffer.delete_line(sel[:start_row]) }
486
+ ctx.buffer.end_change_group
487
+ store_delete_register(ctx, text:, type: :linewise)
488
+ ctx.window.cursor_y = [sel[:start_row], ctx.buffer.line_count - 1].min
489
+ ctx.window.cursor_x = 0
490
+ elsif sel[:mode] == :blockwise
491
+ text = blockwise_selection_text(ctx.buffer, sel)
492
+ ctx.buffer.begin_change_group
493
+ (sel[:start_row]..sel[:end_row]).each do |row|
494
+ line = ctx.buffer.line_at(row)
495
+ s_col = [sel[:start_col], line.length].min
496
+ e_col = [sel[:end_col], line.length].min
497
+ next if e_col <= s_col
498
+
499
+ ctx.buffer.delete_span(row, s_col, row, e_col)
500
+ end
501
+ ctx.buffer.end_change_group
502
+ store_delete_register(ctx, text:, type: :charwise)
503
+ ctx.window.cursor_y = sel[:start_row]
504
+ ctx.window.cursor_x = sel[:start_col]
505
+ else
506
+ text = ctx.buffer.span_text(sel[:start_row], sel[:start_col], sel[:end_row], sel[:end_col])
507
+ ctx.buffer.begin_change_group
508
+ ctx.buffer.delete_span(sel[:start_row], sel[:start_col], sel[:end_row], sel[:end_col])
509
+ ctx.buffer.end_change_group
510
+ store_delete_register(ctx, text:, type: :charwise)
511
+ ctx.window.cursor_y = sel[:start_row]
512
+ ctx.window.cursor_x = sel[:start_col]
513
+ end
514
+ ctx.window.clamp_to_buffer(ctx.buffer)
515
+ ctx.editor.enter_normal_mode
516
+ end
517
+
518
+ def visual_select_text_object(ctx, kwargs:, **)
519
+ motion = (kwargs[:motion] || kwargs["motion"]).to_s
520
+ span = text_object_span(ctx.buffer, ctx.window, motion)
521
+ return false unless span
522
+
523
+ ctx.editor.enter_visual(:visual_char) unless ctx.editor.mode == :visual_char
524
+ v = ctx.editor.visual_state
525
+ v[:anchor_y] = span[:start_row]
526
+ v[:anchor_x] = span[:start_col]
527
+ ctx.window.cursor_y = span[:end_row]
528
+ ctx.window.cursor_x = [span[:end_col] - 1, 0].max
529
+ ctx.window.clamp_to_buffer(ctx.buffer)
530
+ true
531
+ end
532
+
533
+ def clear_message(ctx, **)
534
+ ctx.editor.clear_message
535
+ end
536
+
537
+ def file_write(ctx, argv:, bang:, **)
538
+ path = argv[0]
539
+ target = ctx.buffer.write_to(path)
540
+ size = File.exist?(target) ? File.size(target) : 0
541
+ suffix = bang ? " (force accepted)" : ""
542
+ ctx.editor.echo("\"#{target}\" #{ctx.buffer.line_count}L, #{size}B written#{suffix}")
543
+ end
544
+
545
+ def app_quit(ctx, bang:, **)
546
+ if ctx.editor.window_count > 1
547
+ ctx.editor.close_current_window
548
+ ctx.editor.echo("closed window")
549
+ return
550
+ end
551
+
552
+ if ctx.editor.tabpage_count > 1
553
+ ctx.editor.close_current_tabpage
554
+ ctx.editor.echo("closed tab")
555
+ return
556
+ end
557
+
558
+ if ctx.buffer.modified? && !bang
559
+ ctx.editor.echo_error("No write since last change (add ! to override)")
560
+ return
561
+ end
562
+
563
+ ctx.editor.request_quit!
564
+ end
565
+
566
+ def file_write_quit(ctx, argv:, bang:, **)
567
+ file_write(ctx, argv:, bang:)
568
+ return unless ctx.editor.running?
569
+ app_quit(ctx, bang: true)
570
+ end
571
+
572
+ def file_edit(ctx, argv:, bang:, **)
573
+ path = argv[0]
574
+ if path.nil? || path.empty?
575
+ current_path = ctx.buffer.path
576
+ raise RuVim::CommandError, "Argument required" if current_path.nil? || current_path.empty?
577
+
578
+ if ctx.buffer.modified? && !bang
579
+ ctx.editor.echo_error("Unsaved changes (use :e! to discard)")
580
+ return
581
+ end
582
+
583
+ target = ctx.buffer.reload_from_file!(current_path)
584
+ ctx.window.clamp_to_buffer(ctx.buffer)
585
+ ctx.editor.echo("\"#{target}\" reloaded")
586
+ return
587
+ end
588
+
589
+ if ctx.buffer.modified? && !bang
590
+ ctx.editor.echo_error("Unsaved changes (use :e! to discard and open)")
591
+ return
592
+ end
593
+
594
+ new_buffer = ctx.editor.add_buffer_from_file(path)
595
+ ctx.editor.switch_to_buffer(new_buffer.id)
596
+ ctx.editor.echo(File.exist?(path) ? "\"#{path}\" #{new_buffer.line_count}L" : "\"#{path}\" [New File]")
597
+ end
598
+
599
+ def buffer_list(ctx, **)
600
+ current_id = ctx.buffer.id
601
+ alt_id = ctx.editor.alternate_buffer_id
602
+ items = ctx.editor.buffer_ids.map do |id|
603
+ b = ctx.editor.buffers.fetch(id)
604
+ flags = ""
605
+ flags << "%" if id == current_id
606
+ flags << "#" if id == alt_id
607
+ flags << "+" if b.modified?
608
+ path = b.path || "[No Name]"
609
+ "#{id}#{flags} #{path}"
610
+ end
611
+ ctx.editor.echo(items.join(" | "))
612
+ end
613
+
614
+ def buffer_next(ctx, count:, bang:, **)
615
+ target = ctx.editor.current_buffer.id
616
+ count.times { target = ctx.editor.next_buffer_id_from(target, 1) }
617
+ switch_buffer_id(ctx, target, bang:)
618
+ end
619
+
620
+ def buffer_prev(ctx, count:, bang:, **)
621
+ target = ctx.editor.current_buffer.id
622
+ count.times { target = ctx.editor.next_buffer_id_from(target, -1) }
623
+ switch_buffer_id(ctx, target, bang:)
624
+ end
625
+
626
+ def buffer_switch(ctx, argv:, bang:, **)
627
+ arg = argv[0]
628
+ raise RuVim::CommandError, "Usage: :buffer <id|#>" if arg.nil? || arg.empty?
629
+
630
+ target_id =
631
+ if arg == "#"
632
+ ctx.editor.alternate_buffer_id || raise(RuVim::CommandError, "No alternate buffer")
633
+ elsif arg.match?(/\A\d+\z/)
634
+ arg.to_i
635
+ else
636
+ find_buffer_by_name(ctx.editor, arg)&.id || raise(RuVim::CommandError, "No such buffer: #{arg}")
637
+ end
638
+
639
+ switch_buffer_id(ctx, target_id, bang:)
640
+ end
641
+
642
+ def ex_help(ctx, argv: [], **)
643
+ topic = argv.first.to_s
644
+ registry = RuVim::ExCommandRegistry.instance
645
+
646
+ if topic.empty?
647
+ lines = [
648
+ "RuVim help",
649
+ "",
650
+ "Topics:",
651
+ " :help commands",
652
+ " :help regex",
653
+ " :help options",
654
+ " :help config",
655
+ " :help bindings",
656
+ "",
657
+ "Ex command help:",
658
+ " :help w",
659
+ " :help set",
660
+ " :help buffer"
661
+ ]
662
+ ctx.editor.show_help_buffer!(title: "[Help] help", lines:)
663
+ return
664
+ end
665
+
666
+ key = topic.downcase
667
+ text =
668
+ case key
669
+ when "commands", "command"
670
+ "Ex commands: use :commands (list), :help <name> (detail)"
671
+ when "regex", "search"
672
+ "Regex uses Ruby Regexp (not Vim regex). :%s/pat/rep/g is minimal parser + Ruby regex."
673
+ when "options", "set"
674
+ "Options: use :set/:setlocal/:setglobal. See :help number, :help relativenumber, :help ignorecase, :help smartcase, :help hlsearch"
675
+ when "config"
676
+ "Config: XDG Ruby DSL at ~/.config/ruvim/init.rb and ftplugin/<filetype>.rb"
677
+ when "bindings", "keys", "keymap"
678
+ "Bindings: see docs/binding.md. Ex complement: Tab, insert completion: Ctrl-n/Ctrl-p"
679
+ when "number", "relativenumber", "ignorecase", "smartcase", "hlsearch", "tabstop", "filetype"
680
+ option_help_line(key)
681
+ else
682
+ if (spec = registry.resolve(topic))
683
+ ex_command_help_line(spec)
684
+ else
685
+ "No help for #{topic}. Try :help or :help commands"
686
+ end
687
+ end
688
+ ctx.editor.show_help_buffer!(title: "[Help] #{topic}", lines: help_text_to_lines(topic, text))
689
+ end
690
+
691
+ def ex_define_command(ctx, argv:, bang:, **)
692
+ registry = RuVim::ExCommandRegistry.instance
693
+ if argv.empty?
694
+ items = registry.all.select { |spec| spec.source == :user }.map(&:name)
695
+ ctx.editor.echo(items.empty? ? "No user commands" : "User commands: #{items.join(', ')}")
696
+ return
697
+ end
698
+
699
+ name = argv[0].to_s
700
+ body_tokens = argv[1..] || []
701
+ raise RuVim::CommandError, "Usage: :command Name ex_body" if body_tokens.empty?
702
+
703
+ if registry.registered?(name)
704
+ unless bang
705
+ raise RuVim::CommandError, "Command exists: #{name} (use :command! to replace)"
706
+ end
707
+ registry.unregister(name)
708
+ end
709
+
710
+ body = body_tokens.join(" ")
711
+ handler = lambda do |inner_ctx, argv:, **_k|
712
+ expanded = [body, *argv].join(" ").strip
713
+ Dispatcher.new.dispatch_ex(inner_ctx.editor, expanded)
714
+ end
715
+
716
+ registry.register(name, call: handler, desc: "user-defined", nargs: :any, bang: true, source: :user)
717
+ ctx.editor.echo("Defined :#{name}")
718
+ end
719
+
720
+ def ex_ruby(ctx, argv:, **)
721
+ raise RuVim::CommandError, "Restricted mode: :ruby is disabled" if ctx.editor.respond_to?(:restricted_mode?) && ctx.editor.restricted_mode?
722
+
723
+ code = argv.join(" ")
724
+ raise RuVim::CommandError, "Usage: :ruby <code>" if code.strip.empty?
725
+
726
+ b = binding
727
+ b.local_variable_set(:editor, ctx.editor)
728
+ b.local_variable_set(:buffer, ctx.buffer)
729
+ b.local_variable_set(:window, ctx.window)
730
+ result = eval(code, b) # rubocop:disable Security/Eval
731
+ ctx.editor.echo(result.nil? ? "ruby: nil" : "ruby: #{result.inspect}")
732
+ rescue StandardError => e
733
+ raise RuVim::CommandError, "Ruby error: #{e.class}: #{e.message}"
734
+ end
735
+
736
+ def ex_commands(ctx, **)
737
+ items = RuVim::ExCommandRegistry.instance.all.map do |spec|
738
+ alias_text = spec.aliases.empty? ? "" : " (#{spec.aliases.join(', ')})"
739
+ source = spec.source == :user ? " [user]" : ""
740
+ "#{spec.name}#{alias_text}#{source}"
741
+ end
742
+ ctx.editor.show_help_buffer!(title: "[Commands]", lines: ["Ex commands", "", *items])
743
+ end
744
+
745
+ def ex_set(ctx, argv:, **)
746
+ ex_set_common(ctx, argv, scope: :auto)
747
+ end
748
+
749
+ def ex_setlocal(ctx, argv:, **)
750
+ ex_set_common(ctx, argv, scope: :local)
751
+ end
752
+
753
+ def ex_setglobal(ctx, argv:, **)
754
+ ex_set_common(ctx, argv, scope: :global)
755
+ end
756
+
757
+ def ex_vimgrep(ctx, argv:, **)
758
+ pattern = parse_vimgrep_pattern(argv)
759
+ regex = compile_search_regex(pattern, editor: ctx.editor, window: ctx.window, buffer: ctx.buffer)
760
+ items = grep_items_for_buffers(ctx.editor.buffers.values.select(&:file_buffer?), regex)
761
+ if items.empty?
762
+ ctx.editor.echo_error("Pattern not found: #{pattern}")
763
+ return
764
+ end
765
+
766
+ ctx.editor.set_quickfix_list(items)
767
+ ctx.editor.jump_to_location(ctx.editor.current_quickfix_item)
768
+ ctx.editor.echo("quickfix: #{items.length} item(s)")
769
+ end
770
+
771
+ def ex_lvimgrep(ctx, argv:, **)
772
+ pattern = parse_vimgrep_pattern(argv)
773
+ regex = compile_search_regex(pattern, editor: ctx.editor, window: ctx.window, buffer: ctx.buffer)
774
+ items = grep_items_for_buffers([ctx.buffer], regex)
775
+ if items.empty?
776
+ ctx.editor.echo_error("Pattern not found: #{pattern}")
777
+ return
778
+ end
779
+
780
+ ctx.editor.set_location_list(items, window_id: ctx.window.id)
781
+ ctx.editor.jump_to_location(ctx.editor.current_location_list_item(ctx.window.id))
782
+ ctx.editor.echo("location list: #{items.length} item(s)")
783
+ end
784
+
785
+ def ex_copen(ctx, **)
786
+ open_list_window(ctx, kind: :quickfix, title: "[Quickfix]", lines: quickfix_buffer_lines(ctx.editor))
787
+ end
788
+
789
+ def ex_cclose(ctx, **)
790
+ close_list_windows(ctx.editor, :quickfix)
791
+ end
792
+
793
+ def ex_cnext(ctx, **)
794
+ item = ctx.editor.move_quickfix(+1)
795
+ unless item
796
+ ctx.editor.echo_error("quickfix list is empty")
797
+ return
798
+ end
799
+ ctx.editor.jump_to_location(item)
800
+ ctx.editor.echo(quickfix_item_echo(ctx.editor))
801
+ end
802
+
803
+ def ex_cprev(ctx, **)
804
+ item = ctx.editor.move_quickfix(-1)
805
+ unless item
806
+ ctx.editor.echo_error("quickfix list is empty")
807
+ return
808
+ end
809
+ ctx.editor.jump_to_location(item)
810
+ ctx.editor.echo(quickfix_item_echo(ctx.editor))
811
+ end
812
+
813
+ def ex_lopen(ctx, **)
814
+ open_list_window(ctx, kind: :location_list, title: "[Location List]", lines: location_list_buffer_lines(ctx.editor, ctx.window.id))
815
+ end
816
+
817
+ def ex_lclose(ctx, **)
818
+ close_list_windows(ctx.editor, :location_list)
819
+ end
820
+
821
+ def ex_lnext(ctx, **)
822
+ item = ctx.editor.move_location_list(+1, window_id: ctx.window.id)
823
+ unless item
824
+ ctx.editor.echo_error("location list is empty")
825
+ return
826
+ end
827
+ ctx.editor.jump_to_location(item)
828
+ ctx.editor.echo(location_item_echo(ctx.editor, ctx.window.id))
829
+ end
830
+
831
+ def ex_lprev(ctx, **)
832
+ item = ctx.editor.move_location_list(-1, window_id: ctx.window.id)
833
+ unless item
834
+ ctx.editor.echo_error("location list is empty")
835
+ return
836
+ end
837
+ ctx.editor.jump_to_location(item)
838
+ ctx.editor.echo(location_item_echo(ctx.editor, ctx.window.id))
839
+ end
840
+
841
+ def ex_substitute(ctx, pattern:, replacement:, global: false, **)
842
+ materialize_intro_buffer_if_needed(ctx)
843
+ regex = compile_search_regex(pattern, editor: ctx.editor, window: ctx.window, buffer: ctx.buffer)
844
+ changed = 0
845
+ new_lines = ctx.buffer.lines.map do |line|
846
+ if global
847
+ line.scan(regex) { changed += 1 }
848
+ line.gsub(regex, replacement)
849
+ else
850
+ if line.match?(regex)
851
+ changed += 1
852
+ line.sub(regex, replacement)
853
+ else
854
+ line
855
+ end
856
+ end
857
+ end
858
+
859
+ if changed.positive?
860
+ ctx.buffer.begin_change_group
861
+ ctx.buffer.replace_all_lines!(new_lines)
862
+ ctx.buffer.end_change_group
863
+ ctx.editor.echo("#{changed} substitution(s)")
864
+ else
865
+ ctx.editor.echo("Pattern not found: #{pattern}")
866
+ end
867
+ end
868
+
869
+ def submit_search(ctx, pattern:, direction:)
870
+ text = pattern.to_s
871
+ if text.empty?
872
+ prev = ctx.editor.last_search
873
+ raise RuVim::CommandError, "No previous search" unless prev
874
+ text = prev[:pattern]
875
+ end
876
+ compile_search_regex(text, editor: ctx.editor, window: ctx.window, buffer: ctx.buffer)
877
+ ctx.editor.set_last_search(pattern: text, direction:)
878
+ record_jump(ctx)
879
+ move_to_search(ctx, pattern: text, direction:, count: 1)
880
+ end
881
+
882
+ private
883
+
884
+ def parse_vimgrep_pattern(argv)
885
+ raw = Array(argv).join(" ").strip
886
+ raise RuVim::CommandError, "Usage: :vimgrep /pattern/" if raw.empty?
887
+
888
+ if raw.length >= 2 && raw[0] == raw[-1] && raw[0] !~ /[[:alnum:]\s]/
889
+ raw[1...-1]
890
+ else
891
+ raw
892
+ end
893
+ end
894
+
895
+ def grep_items_for_buffers(buffers, regex)
896
+ Array(buffers).flat_map do |buffer|
897
+ buffer.lines.each_with_index.flat_map do |line, row|
898
+ line.to_enum(:scan, regex).map do
899
+ m = Regexp.last_match
900
+ next unless m
901
+ { buffer_id: buffer.id, row: row, col: m.begin(0), text: line }
902
+ end.compact
903
+ end
904
+ end
905
+ end
906
+
907
+ def quickfix_buffer_lines(editor)
908
+ items = editor.quickfix_items
909
+ return ["Quickfix", "", "(empty)"] if items.empty?
910
+
911
+ idx = editor.quickfix_index || 0
912
+ build_list_buffer_lines(editor, items, idx, title: "Quickfix")
913
+ end
914
+
915
+ def location_list_buffer_lines(editor, window_id)
916
+ items = editor.location_items(window_id)
917
+ idx = editor.location_list(window_id)[:index] || 0
918
+ return ["Location List", "", "(empty)"] if items.empty?
919
+
920
+ build_list_buffer_lines(editor, items, idx, title: "Location List")
921
+ end
922
+
923
+ def build_list_buffer_lines(editor, items, current_index, title:)
924
+ [
925
+ title,
926
+ "",
927
+ *items.each_with_index.map do |it, i|
928
+ b = editor.buffers[it[:buffer_id]]
929
+ path = b&.display_name || "(missing)"
930
+ mark = i == current_index ? ">" : " "
931
+ "#{mark} #{i + 1}: #{path}:#{it[:row] + 1}:#{it[:col] + 1}: #{it[:text]}"
932
+ end
933
+ ]
934
+ end
935
+
936
+ def open_list_window(ctx, kind:, title:, lines:)
937
+ editor = ctx.editor
938
+ editor.split_current_window(layout: :horizontal)
939
+ buffer = editor.add_virtual_buffer(kind:, name: title, lines:, filetype: "qf", readonly: true, modifiable: false)
940
+ editor.switch_to_buffer(buffer.id)
941
+ editor.echo(title)
942
+ buffer
943
+ end
944
+
945
+ def close_list_windows(editor, kind)
946
+ ids = editor.find_window_ids_by_buffer_kind(kind)
947
+ if ids.empty?
948
+ editor.echo_error("#{kind} window is not open")
949
+ return
950
+ end
951
+
952
+ ids.each do |wid|
953
+ break if editor.window_count <= 1
954
+ editor.close_window(wid)
955
+ end
956
+ editor.echo("#{kind} closed")
957
+ end
958
+
959
+ def quickfix_item_echo(editor)
960
+ item = editor.current_quickfix_item
961
+ list_item_echo(editor, item, editor.quickfix_index, editor.quickfix_items.length, label: "qf")
962
+ end
963
+
964
+ def location_item_echo(editor, window_id)
965
+ item = editor.current_location_list_item(window_id)
966
+ list = editor.location_list(window_id)
967
+ list_item_echo(editor, item, list[:index], list[:items].length, label: "ll")
968
+ end
969
+
970
+ def list_item_echo(editor, item, index, total, label:)
971
+ return "#{label}: empty" unless item
972
+
973
+ b = editor.buffers[item[:buffer_id]]
974
+ "#{label} #{index.to_i + 1}/#{total}: #{b&.display_name || '(missing)'}:#{item[:row] + 1}:#{item[:col] + 1}"
975
+ end
976
+
977
+ def switch_buffer_id(ctx, buffer_id, bang: false)
978
+ unless ctx.editor.buffers.key?(buffer_id)
979
+ raise RuVim::CommandError, "No such buffer: #{buffer_id}"
980
+ end
981
+
982
+ if ctx.buffer.modified? && ctx.buffer.id != buffer_id && !bang
983
+ ctx.editor.echo_error("Unsaved changes (use :w or :buffer! / :bnext! / :bprev!)")
984
+ return
985
+ end
986
+
987
+ record_jump(ctx)
988
+ ctx.editor.switch_to_buffer(buffer_id)
989
+ b = ctx.editor.current_buffer
990
+ ctx.editor.echo("#{b.id} #{b.path || '[No Name]'}")
991
+ end
992
+
993
+ def char_at_cursor_for_delete(buffer, row, col)
994
+ line = buffer.line_at(row)
995
+ if col < line.length
996
+ line[col]
997
+ elsif row < buffer.line_count - 1
998
+ "\n"
999
+ else
1000
+ ""
1001
+ end
1002
+ end
1003
+
1004
+ def find_buffer_by_name(editor, token)
1005
+ editor.buffers.values.find do |b|
1006
+ path = b.path.to_s
1007
+ path == token || File.basename(path) == token
1008
+ end
1009
+ end
1010
+
1011
+ def blockwise_selection_text(buffer, sel)
1012
+ (sel[:start_row]..sel[:end_row]).map do |row|
1013
+ line = buffer.line_at(row)
1014
+ s_col = [sel[:start_col], line.length].min
1015
+ e_col = [sel[:end_col], line.length].min
1016
+ line[s_col...e_col].to_s
1017
+ end.join("\n")
1018
+ end
1019
+
1020
+ def search_current_word(ctx, exact:, direction:)
1021
+ word = current_word_under_cursor(ctx.buffer, ctx.window)
1022
+ if word.nil? || word.empty?
1023
+ ctx.editor.echo("No word under cursor")
1024
+ return
1025
+ end
1026
+
1027
+ pattern = exact ? "\\b#{Regexp.escape(word)}\\b" : Regexp.escape(word)
1028
+ ctx.editor.set_last_search(pattern:, direction:)
1029
+ move_to_search(ctx, pattern:, direction:, count: 1)
1030
+ end
1031
+
1032
+ def current_word_under_cursor(buffer, window)
1033
+ line = buffer.line_at(window.cursor_y)
1034
+ return nil if line.empty?
1035
+
1036
+ x = [window.cursor_x, line.length - 1].min
1037
+ return nil if x.negative?
1038
+
1039
+ if line[x] !~ /[[:alnum:]_]/
1040
+ left = x - 1
1041
+ if left >= 0 && line[left] =~ /[[:alnum:]_]/
1042
+ x = left
1043
+ else
1044
+ return nil
1045
+ end
1046
+ end
1047
+
1048
+ s = x
1049
+ s -= 1 while s.positive? && line[s - 1] =~ /[[:alnum:]_]/
1050
+ e = x + 1
1051
+ e += 1 while e < line.length && line[e] =~ /[[:alnum:]_]/
1052
+ line[s...e]
1053
+ end
1054
+
1055
+ def delete_chars_left(ctx, count)
1056
+ return true if count <= 0
1057
+
1058
+ y = ctx.window.cursor_y
1059
+ x = ctx.window.cursor_x
1060
+ start_x = [x - count, 0].max
1061
+ return true if start_x == x
1062
+
1063
+ deleted = ctx.buffer.span_text(y, start_x, y, x)
1064
+ ctx.buffer.begin_change_group
1065
+ ctx.buffer.delete_span(y, start_x, y, x)
1066
+ ctx.buffer.end_change_group
1067
+ store_delete_register(ctx, text: deleted, type: :charwise) unless deleted.empty?
1068
+ ctx.window.cursor_x = start_x
1069
+ ctx.window.clamp_to_buffer(ctx.buffer)
1070
+ true
1071
+ end
1072
+
1073
+ def delete_chars_right(ctx, count)
1074
+ return true if count <= 0
1075
+
1076
+ y = ctx.window.cursor_y
1077
+ x = ctx.window.cursor_x
1078
+ line_len = ctx.buffer.line_length(y)
1079
+ end_x = [x + count, line_len].min
1080
+ return true if end_x == x
1081
+
1082
+ deleted = ctx.buffer.span_text(y, x, y, end_x)
1083
+ ctx.buffer.begin_change_group
1084
+ ctx.buffer.delete_span(y, x, y, end_x)
1085
+ ctx.buffer.end_change_group
1086
+ store_delete_register(ctx, text: deleted, type: :charwise) unless deleted.empty?
1087
+ ctx.window.clamp_to_buffer(ctx.buffer)
1088
+ true
1089
+ end
1090
+
1091
+ def delete_lines_down(ctx, count)
1092
+ total = count + 1
1093
+ deleted = ctx.buffer.line_block_text(ctx.window.cursor_y, total)
1094
+ ctx.buffer.begin_change_group
1095
+ total.times { ctx.buffer.delete_line(ctx.window.cursor_y) }
1096
+ ctx.buffer.end_change_group
1097
+ store_delete_register(ctx, text: deleted, type: :linewise)
1098
+ ctx.window.clamp_to_buffer(ctx.buffer)
1099
+ true
1100
+ end
1101
+
1102
+ def delete_lines_up(ctx, count)
1103
+ y = ctx.window.cursor_y
1104
+ start = [y - count, 0].max
1105
+ total = y - start + 1
1106
+ deleted = ctx.buffer.line_block_text(start, total)
1107
+ ctx.buffer.begin_change_group
1108
+ total.times { ctx.buffer.delete_line(start) }
1109
+ ctx.buffer.end_change_group
1110
+ store_delete_register(ctx, text: deleted, type: :linewise)
1111
+ ctx.window.cursor_y = start
1112
+ ctx.window.cursor_x = 0 if ctx.window.cursor_x > ctx.buffer.line_length(ctx.window.cursor_y)
1113
+ ctx.window.clamp_to_buffer(ctx.buffer)
1114
+ true
1115
+ end
1116
+
1117
+ def delete_to_end_of_line(ctx)
1118
+ y = ctx.window.cursor_y
1119
+ x = ctx.window.cursor_x
1120
+ line_len = ctx.buffer.line_length(y)
1121
+ return true if x >= line_len
1122
+
1123
+ deleted = ctx.buffer.span_text(y, x, y, line_len)
1124
+ ctx.buffer.begin_change_group
1125
+ ctx.buffer.delete_span(y, x, y, line_len)
1126
+ ctx.buffer.end_change_group
1127
+ store_delete_register(ctx, text: deleted, type: :charwise) unless deleted.empty?
1128
+ ctx.window.clamp_to_buffer(ctx.buffer)
1129
+ true
1130
+ end
1131
+
1132
+ def delete_word_forward(ctx, count)
1133
+ y = ctx.window.cursor_y
1134
+ x = ctx.window.cursor_x
1135
+ target = advance_word_forward(ctx.buffer, y, x, count)
1136
+ return true unless target
1137
+
1138
+ deleted = ctx.buffer.span_text(y, x, target[:row], target[:col])
1139
+ ctx.buffer.begin_change_group
1140
+ ctx.buffer.delete_span(y, x, target[:row], target[:col])
1141
+ ctx.buffer.end_change_group
1142
+ store_delete_register(ctx, text: deleted, type: :charwise) unless deleted.empty?
1143
+ ctx.window.clamp_to_buffer(ctx.buffer)
1144
+ true
1145
+ end
1146
+
1147
+ def delete_text_object_word(ctx, around:)
1148
+ span = word_object_span(ctx.buffer, ctx.window, around:)
1149
+ return false unless span
1150
+
1151
+ text = ctx.buffer.span_text(span[:start_row], span[:start_col], span[:end_row], span[:end_col])
1152
+ ctx.buffer.begin_change_group
1153
+ ctx.buffer.delete_span(span[:start_row], span[:start_col], span[:end_row], span[:end_col])
1154
+ ctx.buffer.end_change_group
1155
+ store_delete_register(ctx, text:, type: :charwise) unless text.empty?
1156
+ ctx.window.cursor_y = span[:start_row]
1157
+ ctx.window.cursor_x = span[:start_col]
1158
+ ctx.window.clamp_to_buffer(ctx.buffer)
1159
+ true
1160
+ end
1161
+
1162
+ def delete_text_object(ctx, motion)
1163
+ span = text_object_span(ctx.buffer, ctx.window, motion)
1164
+ return false unless span
1165
+
1166
+ text = ctx.buffer.span_text(span[:start_row], span[:start_col], span[:end_row], span[:end_col])
1167
+ ctx.buffer.begin_change_group
1168
+ ctx.buffer.delete_span(span[:start_row], span[:start_col], span[:end_row], span[:end_col])
1169
+ ctx.buffer.end_change_group
1170
+ store_delete_register(ctx, text:, type: :charwise) unless text.empty?
1171
+ ctx.window.cursor_y = span[:start_row]
1172
+ ctx.window.cursor_x = span[:start_col]
1173
+ ctx.window.clamp_to_buffer(ctx.buffer)
1174
+ true
1175
+ end
1176
+
1177
+ def yank_text_object_word(ctx, around:)
1178
+ span = word_object_span(ctx.buffer, ctx.window, around:)
1179
+ return false unless span
1180
+
1181
+ text = ctx.buffer.span_text(span[:start_row], span[:start_col], span[:end_row], span[:end_col])
1182
+ store_yank_register(ctx, text:, type: :charwise) unless text.empty?
1183
+ ctx.editor.echo("yanked")
1184
+ true
1185
+ end
1186
+
1187
+ def yank_text_object(ctx, motion)
1188
+ span = text_object_span(ctx.buffer, ctx.window, motion)
1189
+ return false unless span
1190
+
1191
+ text = ctx.buffer.span_text(span[:start_row], span[:start_col], span[:end_row], span[:end_col])
1192
+ store_yank_register(ctx, text:, type: :charwise) unless text.empty?
1193
+ ctx.editor.echo("yanked")
1194
+ true
1195
+ end
1196
+
1197
+ def advance_word_forward(buffer, row, col, count)
1198
+ text = buffer.lines.join("\n")
1199
+ flat = cursor_to_offset(buffer, row, col)
1200
+ idx = flat
1201
+ count.times do
1202
+ idx = next_word_start_offset(text, idx)
1203
+ return nil unless idx
1204
+ end
1205
+ offset_to_cursor(buffer, idx)
1206
+ end
1207
+
1208
+ def move_cursor_word(ctx, count:, kind:)
1209
+ buffer = ctx.buffer
1210
+ row = ctx.window.cursor_y
1211
+ col = ctx.window.cursor_x
1212
+ count = 1 if count.to_i <= 0
1213
+ target = { row:, col: }
1214
+ count.times do
1215
+ target =
1216
+ case kind
1217
+ when :forward_start then advance_word_forward(buffer, target[:row], target[:col], 1)
1218
+ when :backward_start then advance_word_backward(buffer, target[:row], target[:col], 1)
1219
+ when :forward_end then advance_word_end(buffer, target[:row], target[:col], 1)
1220
+ end
1221
+ break unless target
1222
+ end
1223
+ return unless target
1224
+
1225
+ ctx.window.cursor_y = target[:row]
1226
+ ctx.window.cursor_x = target[:col]
1227
+ ctx.window.clamp_to_buffer(buffer)
1228
+ end
1229
+
1230
+ def advance_word_backward(buffer, row, col, _count)
1231
+ text = buffer.lines.join("\n")
1232
+ idx = cursor_to_offset(buffer, row, col)
1233
+ idx = [idx - 1, 0].max
1234
+ while idx > 0 && char_class(text[idx]) == :space
1235
+ idx -= 1
1236
+ end
1237
+ cls = char_class(text[idx])
1238
+ while idx > 0 && char_class(text[idx - 1]) == cls && cls != :space
1239
+ idx -= 1
1240
+ end
1241
+ while idx > 0 && char_class(text[idx]) == :space
1242
+ idx += 1
1243
+ end
1244
+ offset_to_cursor(buffer, idx)
1245
+ end
1246
+
1247
+ def advance_word_end(buffer, row, col, _count)
1248
+ text = buffer.lines.join("\n")
1249
+ idx = cursor_to_offset(buffer, row, col)
1250
+ n = text.length
1251
+ while idx < n && char_class(text[idx]) == :space
1252
+ idx += 1
1253
+ end
1254
+ return nil if idx >= n
1255
+
1256
+ cls = char_class(text[idx])
1257
+ idx += 1 while idx + 1 < n && char_class(text[idx + 1]) == cls && cls != :space
1258
+ offset_to_cursor(buffer, idx)
1259
+ end
1260
+
1261
+ def next_word_start_offset(text, from_offset)
1262
+ i = [from_offset, 0].max
1263
+ n = text.length
1264
+ return nil if i >= n
1265
+
1266
+ cls = char_class(text[i])
1267
+ if cls == :word
1268
+ i += 1 while i < n && char_class(text[i]) == :word
1269
+ elsif cls == :space
1270
+ i += 1 while i < n && char_class(text[i]) == :space
1271
+ else
1272
+ i += 1
1273
+ end
1274
+ i += 1 while i < n && char_class(text[i]) == :space
1275
+ return n if i > n
1276
+
1277
+ i <= n ? i : nil
1278
+ end
1279
+
1280
+ def char_class(ch)
1281
+ return :space if ch == "\n"
1282
+ return :space if ch =~ /\s/
1283
+ return :word if ch =~ /[[:alnum:]_]/
1284
+ :punct
1285
+ end
1286
+
1287
+ def word_object_span(buffer, window, around:)
1288
+ row = window.cursor_y
1289
+ line = buffer.line_at(row)
1290
+ return nil if line.empty?
1291
+
1292
+ x = [window.cursor_x, line.length - 1].min
1293
+ x = 0 if x.negative?
1294
+
1295
+ if x < line.length && line[x] =~ /\s/
1296
+ if around
1297
+ left = x
1298
+ left -= 1 while left.positive? && line[left - 1] =~ /\s/
1299
+ right = x
1300
+ right += 1 while right < line.length && line[right] =~ /\s/
1301
+ return { start_row: row, start_col: left, end_row: row, end_col: right }
1302
+ end
1303
+
1304
+ nxt = line.index(/\S/, x)
1305
+ return nil unless nxt
1306
+ x = nxt
1307
+ end
1308
+
1309
+ cls = line[x] =~ /[[:alnum:]_]/ ? :word : :punct
1310
+ start_col = x
1311
+ start_col -= 1 while start_col.positive? && same_word_class?(line[start_col - 1], cls)
1312
+ end_col = x + 1
1313
+ end_col += 1 while end_col < line.length && same_word_class?(line[end_col], cls)
1314
+
1315
+ if around
1316
+ while end_col < line.length && line[end_col] =~ /\s/
1317
+ end_col += 1
1318
+ end
1319
+ end
1320
+
1321
+ { start_row: row, start_col:, end_row: row, end_col: }
1322
+ end
1323
+
1324
+ def text_object_span(buffer, window, motion)
1325
+ around = motion.start_with?("a")
1326
+ kind = motion[1..]
1327
+ case kind
1328
+ when "w"
1329
+ word_object_span(buffer, window, around:)
1330
+ when "p"
1331
+ paragraph_object_span(buffer, window, around:)
1332
+ when '"'
1333
+ quote_object_span(buffer, window, quote: '"', around:)
1334
+ when "'"
1335
+ quote_object_span(buffer, window, quote: "'", around:)
1336
+ when "`"
1337
+ quote_object_span(buffer, window, quote: "`", around:)
1338
+ when ")"
1339
+ paren_object_span(buffer, window, open: "(", close: ")", around:)
1340
+ when "]"
1341
+ paren_object_span(buffer, window, open: "[", close: "]", around:)
1342
+ when "}"
1343
+ paren_object_span(buffer, window, open: "{", close: "}", around:)
1344
+ else
1345
+ nil
1346
+ end
1347
+ end
1348
+
1349
+ def text_object_motion?(motion)
1350
+ motion.is_a?(String) && motion.match?(/\A[ia](w|p|["'`\)\]\}])\z/)
1351
+ end
1352
+
1353
+ def quote_object_span(buffer, window, quote:, around:)
1354
+ row = window.cursor_y
1355
+ line = buffer.line_at(row)
1356
+ return nil if line.empty?
1357
+ x = [window.cursor_x, line.length - 1].min
1358
+ return nil if x.negative?
1359
+
1360
+ left = find_left_quote(line, x, quote)
1361
+ right_from = [x, (left ? left + 1 : 0)].max
1362
+ right = find_right_quote(line, right_from, quote)
1363
+ return nil unless left && right && left < right
1364
+
1365
+ if around
1366
+ { start_row: row, start_col: left, end_row: row, end_col: right + 1 }
1367
+ else
1368
+ { start_row: row, start_col: left + 1, end_row: row, end_col: right }
1369
+ end
1370
+ end
1371
+
1372
+ def paren_object_span(buffer, window, open:, close:, around:)
1373
+ row = window.cursor_y
1374
+ line = buffer.line_at(row)
1375
+ return nil if line.empty?
1376
+ x = [window.cursor_x, line.length - 1].min
1377
+ return nil if x.negative?
1378
+
1379
+ left = find_matching_left_delim(line, x, open:, close:)
1380
+ right = find_matching_right_delim(line, [x, left || 0].max, open:, close:)
1381
+ return nil unless left && right && left < right
1382
+
1383
+ if around
1384
+ { start_row: row, start_col: left, end_row: row, end_col: right + 1 }
1385
+ else
1386
+ { start_row: row, start_col: left + 1, end_row: row, end_col: right }
1387
+ end
1388
+ end
1389
+
1390
+ def paragraph_object_span(buffer, window, around:)
1391
+ row = [[window.cursor_y, 0].max, buffer.line_count - 1].min
1392
+ return nil if row.negative?
1393
+
1394
+ blank = buffer.line_at(row).strip.empty?
1395
+ start_row = row
1396
+ end_row = row
1397
+
1398
+ while start_row.positive? && (buffer.line_at(start_row - 1).strip.empty? == blank)
1399
+ start_row -= 1
1400
+ end
1401
+ while end_row + 1 < buffer.line_count && (buffer.line_at(end_row + 1).strip.empty? == blank)
1402
+ end_row += 1
1403
+ end
1404
+
1405
+ if around && !blank
1406
+ if end_row + 1 < buffer.line_count && buffer.line_at(end_row + 1).strip.empty?
1407
+ while end_row + 1 < buffer.line_count && buffer.line_at(end_row + 1).strip.empty?
1408
+ end_row += 1
1409
+ end
1410
+ elsif start_row.positive? && buffer.line_at(start_row - 1).strip.empty?
1411
+ while start_row.positive? && buffer.line_at(start_row - 1).strip.empty?
1412
+ start_row -= 1
1413
+ end
1414
+ end
1415
+ end
1416
+
1417
+ if around && end_row + 1 < buffer.line_count
1418
+ {
1419
+ start_row: start_row,
1420
+ start_col: 0,
1421
+ end_row: end_row + 1,
1422
+ end_col: 0
1423
+ }
1424
+ else
1425
+ {
1426
+ start_row: start_row,
1427
+ start_col: 0,
1428
+ end_row: end_row,
1429
+ end_col: buffer.line_length(end_row)
1430
+ }
1431
+ end
1432
+ end
1433
+
1434
+ def find_left_quote(line, x, quote)
1435
+ i = x
1436
+ while i >= 0
1437
+ return i if line[i] == quote && !escaped?(line, i)
1438
+ i -= 1
1439
+ end
1440
+ nil
1441
+ end
1442
+
1443
+ def find_right_quote(line, x, quote)
1444
+ i = x
1445
+ while i < line.length
1446
+ return i if line[i] == quote && !escaped?(line, i)
1447
+ i += 1
1448
+ end
1449
+ nil
1450
+ end
1451
+
1452
+ def escaped?(line, idx)
1453
+ backslashes = 0
1454
+ i = idx - 1
1455
+ while i >= 0 && line[i] == "\\"
1456
+ backslashes += 1
1457
+ i -= 1
1458
+ end
1459
+ backslashes.odd?
1460
+ end
1461
+
1462
+ def find_matching_left_delim(line, x, open:, close:)
1463
+ depth = 0
1464
+ i = x
1465
+ while i >= 0
1466
+ ch = line[i]
1467
+ if ch == close
1468
+ depth += 1
1469
+ elsif ch == open
1470
+ return i if depth.zero?
1471
+ depth -= 1
1472
+ end
1473
+ i -= 1
1474
+ end
1475
+ nil
1476
+ end
1477
+
1478
+ def find_matching_right_delim(line, x, open:, close:)
1479
+ depth = 0
1480
+ i = x
1481
+ while i < line.length
1482
+ ch = line[i]
1483
+ if ch == open
1484
+ depth += 1
1485
+ elsif ch == close
1486
+ if depth <= 1
1487
+ return i
1488
+ end
1489
+ depth -= 1
1490
+ end
1491
+ i += 1
1492
+ end
1493
+ nil
1494
+ end
1495
+
1496
+ def same_word_class?(ch, cls)
1497
+ return false if ch.nil?
1498
+ case cls
1499
+ when :word then ch =~ /[[:alnum:]_]/
1500
+ when :punct then !(ch =~ /[[:alnum:]_\s]/)
1501
+ else false
1502
+ end
1503
+ end
1504
+
1505
+ def cursor_to_offset(buffer, row, col)
1506
+ offset = 0
1507
+ row.times { |r| offset += buffer.line_length(r) + 1 }
1508
+ offset + col
1509
+ end
1510
+
1511
+ def offset_to_cursor(buffer, offset)
1512
+ remaining = offset
1513
+ (0...buffer.line_count).each do |row|
1514
+ len = buffer.line_length(row)
1515
+ return { row:, col: [remaining, len].min } if remaining <= len
1516
+ remaining -= (len + 1)
1517
+ end
1518
+ { row: buffer.line_count - 1, col: buffer.line_length(buffer.line_count - 1) }
1519
+ end
1520
+
1521
+ def paste_register(ctx, before:, count:)
1522
+ reg_name = ctx.editor.consume_active_register("\"")
1523
+ reg = ctx.editor.get_register(reg_name)
1524
+ unless reg
1525
+ ctx.editor.echo("Register is empty")
1526
+ return
1527
+ end
1528
+
1529
+ if reg[:type] == :linewise
1530
+ paste_linewise(ctx, reg[:text], before:, count:)
1531
+ else
1532
+ paste_charwise(ctx, reg[:text], before:, count:)
1533
+ end
1534
+ end
1535
+
1536
+ def paste_linewise(ctx, text, before:, count:)
1537
+ lines = text.sub(/\n\z/, "").split("\n", -1)
1538
+ return if lines.empty?
1539
+
1540
+ insert_at = before ? ctx.window.cursor_y : (ctx.window.cursor_y + 1)
1541
+ ctx.buffer.begin_change_group
1542
+ count.times { ctx.buffer.insert_lines_at(insert_at, lines) }
1543
+ ctx.buffer.end_change_group
1544
+ ctx.window.cursor_y = insert_at
1545
+ ctx.window.cursor_x = 0
1546
+ ctx.window.clamp_to_buffer(ctx.buffer)
1547
+ end
1548
+
1549
+ def store_register(ctx, text:, type:, kind: :generic)
1550
+ name = ctx.editor.consume_active_register("\"")
1551
+ if kind == :generic
1552
+ ctx.editor.set_register(name, text:, type:)
1553
+ else
1554
+ ctx.editor.store_operator_register(name, text:, type:, kind:)
1555
+ end
1556
+ end
1557
+
1558
+ def store_delete_register(ctx, text:, type:)
1559
+ store_register(ctx, text:, type:, kind: :delete)
1560
+ end
1561
+
1562
+ def store_yank_register(ctx, text:, type:)
1563
+ store_register(ctx, text:, type:, kind: :yank)
1564
+ end
1565
+
1566
+ def record_jump(ctx)
1567
+ ctx.editor.push_jump_location(ctx.editor.current_location)
1568
+ end
1569
+
1570
+ def find_matching_bracket(buffer, row, col, open_ch, close_ch, direction)
1571
+ depth = 1
1572
+ pos = { row: row, col: col }
1573
+ loop do
1574
+ pos = direction == :forward ? next_buffer_pos(buffer, pos[:row], pos[:col]) : prev_buffer_pos(buffer, pos[:row], pos[:col])
1575
+ return nil unless pos
1576
+
1577
+ ch = buffer.line_at(pos[:row])[pos[:col]]
1578
+ next unless ch
1579
+
1580
+ if ch == open_ch
1581
+ depth += 1
1582
+ elsif ch == close_ch
1583
+ depth -= 1
1584
+ return pos if depth.zero?
1585
+ end
1586
+ end
1587
+ end
1588
+
1589
+ def next_buffer_pos(buffer, row, col)
1590
+ line = buffer.line_at(row)
1591
+ if col + 1 < line.length
1592
+ { row: row, col: col + 1 }
1593
+ elsif row + 1 < buffer.line_count
1594
+ { row: row + 1, col: 0 }
1595
+ end
1596
+ end
1597
+
1598
+ def prev_buffer_pos(buffer, row, col)
1599
+ if col.positive?
1600
+ { row: row, col: col - 1 }
1601
+ elsif row.positive?
1602
+ prev_row = row - 1
1603
+ prev_len = buffer.line_length(prev_row)
1604
+ return { row: prev_row, col: prev_len - 1 } if prev_len.positive?
1605
+
1606
+ { row: prev_row, col: 0 }
1607
+ end
1608
+ end
1609
+
1610
+ def materialize_intro_buffer_if_needed(ctx)
1611
+ ctx.editor.materialize_intro_buffer!
1612
+ nil
1613
+ end
1614
+
1615
+ def ex_set_common(ctx, argv, scope:)
1616
+ editor = ctx.editor
1617
+ if argv.empty?
1618
+ items = editor.option_snapshot(window: ctx.window, buffer: ctx.buffer).map do |opt|
1619
+ format_option_value(opt[:name], opt[:effective])
1620
+ end
1621
+ ctx.editor.echo(items.join(" "))
1622
+ return
1623
+ end
1624
+
1625
+ output = []
1626
+ argv.each do |token|
1627
+ output.concat(handle_set_token(ctx, token, scope:))
1628
+ end
1629
+ ctx.editor.echo(output.join(" ")) unless output.empty?
1630
+ end
1631
+
1632
+ def handle_set_token(ctx, token, scope:)
1633
+ t = token.to_s
1634
+ return [] if t.empty?
1635
+
1636
+ if t.end_with?("?")
1637
+ name = t[0...-1]
1638
+ val = ctx.editor.get_option(name, scope: resolve_option_scope(ctx.editor, name, scope), window: ctx.window, buffer: ctx.buffer)
1639
+ return ["#{name}=#{format_option_scalar(val)}"]
1640
+ end
1641
+
1642
+ if t.start_with?("no")
1643
+ name = t[2..]
1644
+ return [apply_bool_option(ctx, name, false, scope:)]
1645
+ end
1646
+
1647
+ if t.start_with?("inv")
1648
+ name = t[3..]
1649
+ cur = !!ctx.editor.get_option(name, scope: :effective, window: ctx.window, buffer: ctx.buffer)
1650
+ return [apply_bool_option(ctx, name, !cur, scope:)]
1651
+ end
1652
+
1653
+ if t.include?("=")
1654
+ name, raw = t.split("=", 2)
1655
+ val = parse_option_value(ctx.editor, name, raw)
1656
+ applied = ctx.editor.set_option(name, val, scope: resolve_option_scope(ctx.editor, name, scope), window: ctx.window, buffer: ctx.buffer)
1657
+ return ["#{name}=#{format_option_scalar(applied)}"]
1658
+ end
1659
+
1660
+ if bool_option?(ctx.editor, t)
1661
+ return [apply_bool_option(ctx, t, true, scope:)]
1662
+ end
1663
+
1664
+ val = ctx.editor.get_option(t, scope: resolve_option_scope(ctx.editor, t, scope), window: ctx.window, buffer: ctx.buffer)
1665
+ ["#{t}=#{format_option_scalar(val)}"]
1666
+ end
1667
+
1668
+ def apply_bool_option(ctx, name, value, scope:)
1669
+ unless bool_option?(ctx.editor, name)
1670
+ raise RuVim::CommandError, "#{name} is not a boolean option"
1671
+ end
1672
+ applied = ctx.editor.set_option(name, value, scope: resolve_option_scope(ctx.editor, name, scope), window: ctx.window, buffer: ctx.buffer)
1673
+ applied ? name.to_s : "no#{name}"
1674
+ end
1675
+
1676
+ def resolve_option_scope(editor, name, requested_scope)
1677
+ case requested_scope
1678
+ when :auto
1679
+ :auto
1680
+ when :global
1681
+ :global
1682
+ when :local
1683
+ editor.option_default_scope(name) == :buffer ? :buffer : :window
1684
+ else
1685
+ requested_scope
1686
+ end
1687
+ end
1688
+
1689
+ def parse_option_value(editor, name, raw)
1690
+ defn = editor.option_def(name)
1691
+ return raw unless defn
1692
+
1693
+ case defn[:type]
1694
+ when :bool
1695
+ parse_bool(raw)
1696
+ when :int
1697
+ Integer(raw)
1698
+ else
1699
+ raw
1700
+ end
1701
+ rescue ArgumentError
1702
+ raise RuVim::CommandError, "Invalid value for #{name}: #{raw}"
1703
+ end
1704
+
1705
+ def parse_bool(raw)
1706
+ case raw.to_s.downcase
1707
+ when "1", "true", "on", "yes" then true
1708
+ when "0", "false", "off", "no" then false
1709
+ else
1710
+ raise ArgumentError
1711
+ end
1712
+ end
1713
+
1714
+ def bool_option?(editor, name)
1715
+ editor.option_def(name)&.dig(:type) == :bool
1716
+ end
1717
+
1718
+ def format_option_value(name, value)
1719
+ if value == true
1720
+ name.to_s
1721
+ elsif value == false
1722
+ "no#{name}"
1723
+ else
1724
+ "#{name}=#{format_option_scalar(value)}"
1725
+ end
1726
+ end
1727
+
1728
+ def format_option_scalar(value)
1729
+ value.nil? ? "nil" : value.to_s
1730
+ end
1731
+
1732
+ def ex_command_help_line(spec)
1733
+ aliases = spec.aliases.empty? ? "" : " aliases=#{spec.aliases.join(',')}"
1734
+ nargs = " nargs=#{spec.nargs}"
1735
+ bang = spec.bang ? " !" : ""
1736
+ src = spec.source == :user ? " [user]" : ""
1737
+ ":#{spec.name}#{bang} - #{spec.desc}#{aliases}#{nargs}#{src}"
1738
+ end
1739
+
1740
+ def option_help_line(name)
1741
+ case name
1742
+ when "number"
1743
+ "number (bool, window-local): line numbers. :set number / :set nonumber"
1744
+ when "tabstop"
1745
+ "tabstop (int, buffer-local): tab display width. ex: :set tabstop=4"
1746
+ when "relativenumber"
1747
+ "relativenumber (bool, window-local): show relative line numbers. ex: :set relativenumber"
1748
+ when "ignorecase"
1749
+ "ignorecase (bool, global): case-insensitive search unless smartcase + uppercase pattern"
1750
+ when "smartcase"
1751
+ "smartcase (bool, global): with ignorecase, uppercase in pattern makes search case-sensitive"
1752
+ when "hlsearch"
1753
+ "hlsearch (bool, global): highlight search matches on screen"
1754
+ when "filetype"
1755
+ "filetype (string, buffer-local): used for ftplugin and filetype-local keymaps"
1756
+ else
1757
+ "No option help: #{name}"
1758
+ end
1759
+ end
1760
+
1761
+ def help_text_to_lines(topic, text)
1762
+ [
1763
+ "RuVim help: #{topic}",
1764
+ "",
1765
+ *text.to_s.scan(/.{1,78}(?:\s+|$)|.{1,78}/).map(&:rstrip)
1766
+ ]
1767
+ end
1768
+
1769
+ def paste_charwise(ctx, text, before:, count:)
1770
+ y = ctx.window.cursor_y
1771
+ x = ctx.window.cursor_x
1772
+ insert_col = before ? x : [x + 1, ctx.buffer.line_length(y)].min
1773
+
1774
+ ctx.buffer.begin_change_group
1775
+ count.times do
1776
+ y, insert_col = ctx.buffer.insert_text(y, insert_col, text)
1777
+ end
1778
+ ctx.buffer.end_change_group
1779
+ ctx.window.cursor_y = y
1780
+ ctx.window.cursor_x = [insert_col - 1, 0].max
1781
+ ctx.window.clamp_to_buffer(ctx.buffer)
1782
+ end
1783
+
1784
+ def repeat_search(ctx, forward:, count:)
1785
+ prev = ctx.editor.last_search
1786
+ unless prev
1787
+ ctx.editor.echo("No previous search")
1788
+ return
1789
+ end
1790
+
1791
+ direction = forward ? prev[:direction] : invert_direction(prev[:direction])
1792
+ move_to_search(ctx, pattern: prev[:pattern], direction:, count:)
1793
+ end
1794
+
1795
+ def invert_direction(direction)
1796
+ direction.to_sym == :forward ? :backward : :forward
1797
+ end
1798
+
1799
+ def move_to_search(ctx, pattern:, direction:, count:)
1800
+ count = 1 if count.to_i <= 0
1801
+ regex = compile_search_regex(pattern, editor: ctx.editor, window: ctx.window, buffer: ctx.buffer)
1802
+ count.times do
1803
+ match = find_next_match(ctx.buffer, ctx.window, regex, direction: direction)
1804
+ unless match
1805
+ ctx.editor.echo("Pattern not found: #{pattern}")
1806
+ return
1807
+ end
1808
+ ctx.window.cursor_y = match[:row]
1809
+ ctx.window.cursor_x = match[:col]
1810
+ end
1811
+ ctx.editor.echo("/#{pattern}")
1812
+ end
1813
+
1814
+ def find_next_match(buffer, window, regex, direction:)
1815
+ return nil unless regex
1816
+
1817
+ if direction.to_sym == :backward
1818
+ find_backward_match(buffer, window, regex)
1819
+ else
1820
+ find_forward_match(buffer, window, regex)
1821
+ end
1822
+ end
1823
+
1824
+ def find_forward_match(buffer, window, regex)
1825
+ start_row = window.cursor_y
1826
+ start_col = window.cursor_x + 1
1827
+ last_row = buffer.line_count - 1
1828
+
1829
+ (0..last_row).each do |offset|
1830
+ row = (start_row + offset) % (last_row + 1)
1831
+ line = buffer.line_at(row)
1832
+ col_from = row == start_row ? start_col : 0
1833
+ m = regex.match(line, col_from)
1834
+ return { row:, col: m.begin(0) } if m
1835
+ end
1836
+ nil
1837
+ end
1838
+
1839
+ def find_backward_match(buffer, window, regex)
1840
+ start_row = window.cursor_y
1841
+ start_col = [window.cursor_x - 1, buffer.line_length(start_row)].min
1842
+ last_row = buffer.line_count - 1
1843
+
1844
+ (0..last_row).each do |offset|
1845
+ row = (start_row - offset) % (last_row + 1)
1846
+ line = buffer.line_at(row)
1847
+ idx = last_regex_match_before(line, regex, row == start_row ? start_col : line.length)
1848
+ return { row:, col: idx } if idx
1849
+ end
1850
+ nil
1851
+ end
1852
+
1853
+ def last_regex_match_before(line, regex, max_col)
1854
+ idx = nil
1855
+ offset = 0
1856
+ while (m = regex.match(line, offset))
1857
+ break if m.begin(0) > max_col
1858
+ idx = m.begin(0) if m.begin(0) <= max_col
1859
+ next_offset = m.begin(0) == m.end(0) ? m.begin(0) + 1 : m.end(0)
1860
+ break if next_offset > line.length
1861
+ offset = next_offset
1862
+ end
1863
+ idx
1864
+ end
1865
+
1866
+ def compile_search_regex(pattern, editor: nil, window: nil, buffer: nil)
1867
+ flags = search_regexp_flags(pattern.to_s, editor:, window:, buffer:)
1868
+ Regexp.new(pattern.to_s, flags)
1869
+ rescue RegexpError => e
1870
+ raise RuVim::CommandError, "Invalid regex: #{e.message}"
1871
+ end
1872
+
1873
+ def search_regexp_flags(pattern, editor:, window:, buffer:)
1874
+ return 0 unless editor
1875
+
1876
+ ignorecase = !!editor.effective_option("ignorecase", window:, buffer:)
1877
+ return 0 unless ignorecase
1878
+
1879
+ smartcase = !!editor.effective_option("smartcase", window:, buffer:)
1880
+ if smartcase && pattern.match?(/[A-Z]/)
1881
+ 0
1882
+ else
1883
+ Regexp::IGNORECASE
1884
+ end
1885
+ rescue StandardError
1886
+ 0
1887
+ end
1888
+ end
1889
+ end