ruvim 0.1.0 → 0.3.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +4 -0
  3. data/AGENTS.md +84 -0
  4. data/CLAUDE.md +1 -0
  5. data/docs/binding.md +29 -0
  6. data/docs/command.md +101 -0
  7. data/docs/config.md +203 -84
  8. data/docs/done.md +21 -0
  9. data/docs/lib_cleanup_report.md +79 -0
  10. data/docs/plugin.md +13 -15
  11. data/docs/spec.md +195 -33
  12. data/docs/todo.md +183 -10
  13. data/docs/tutorial.md +1 -1
  14. data/docs/vim_diff.md +94 -171
  15. data/lib/ruvim/app.rb +1543 -172
  16. data/lib/ruvim/buffer.rb +35 -1
  17. data/lib/ruvim/cli.rb +12 -3
  18. data/lib/ruvim/clipboard.rb +2 -0
  19. data/lib/ruvim/command_invocation.rb +3 -1
  20. data/lib/ruvim/command_line.rb +2 -0
  21. data/lib/ruvim/command_registry.rb +2 -0
  22. data/lib/ruvim/config_dsl.rb +2 -0
  23. data/lib/ruvim/config_loader.rb +21 -5
  24. data/lib/ruvim/context.rb +2 -7
  25. data/lib/ruvim/dispatcher.rb +153 -13
  26. data/lib/ruvim/display_width.rb +28 -2
  27. data/lib/ruvim/editor.rb +622 -69
  28. data/lib/ruvim/ex_command_registry.rb +2 -0
  29. data/lib/ruvim/global_commands.rb +1386 -114
  30. data/lib/ruvim/highlighter.rb +16 -21
  31. data/lib/ruvim/input.rb +52 -29
  32. data/lib/ruvim/keymap_manager.rb +83 -0
  33. data/lib/ruvim/keyword_chars.rb +48 -0
  34. data/lib/ruvim/lang/base.rb +25 -0
  35. data/lib/ruvim/lang/csv.rb +18 -0
  36. data/lib/ruvim/lang/json.rb +18 -0
  37. data/lib/ruvim/lang/markdown.rb +170 -0
  38. data/lib/ruvim/lang/ruby.rb +236 -0
  39. data/lib/ruvim/lang/scheme.rb +44 -0
  40. data/lib/ruvim/lang/tsv.rb +19 -0
  41. data/lib/ruvim/rich_view/markdown_renderer.rb +248 -0
  42. data/lib/ruvim/rich_view/table_renderer.rb +176 -0
  43. data/lib/ruvim/rich_view.rb +93 -0
  44. data/lib/ruvim/screen.rb +851 -119
  45. data/lib/ruvim/terminal.rb +18 -1
  46. data/lib/ruvim/text_metrics.rb +28 -0
  47. data/lib/ruvim/version.rb +2 -2
  48. data/lib/ruvim/window.rb +37 -10
  49. data/lib/ruvim.rb +15 -0
  50. data/test/app_completion_test.rb +174 -0
  51. data/test/app_dot_repeat_test.rb +13 -0
  52. data/test/app_motion_test.rb +110 -2
  53. data/test/app_scenario_test.rb +998 -0
  54. data/test/app_startup_test.rb +197 -0
  55. data/test/arglist_test.rb +113 -0
  56. data/test/buffer_test.rb +49 -30
  57. data/test/config_loader_test.rb +37 -0
  58. data/test/dispatcher_test.rb +438 -0
  59. data/test/display_width_test.rb +18 -0
  60. data/test/editor_register_test.rb +23 -0
  61. data/test/fixtures/render_basic_snapshot.txt +7 -8
  62. data/test/fixtures/render_basic_snapshot_nonumber.txt +1 -2
  63. data/test/fixtures/render_unicode_scrolled_snapshot.txt +6 -7
  64. data/test/highlighter_test.rb +121 -0
  65. data/test/indent_test.rb +201 -0
  66. data/test/input_screen_integration_test.rb +65 -14
  67. data/test/markdown_renderer_test.rb +279 -0
  68. data/test/on_save_hook_test.rb +150 -0
  69. data/test/rich_view_test.rb +478 -0
  70. data/test/screen_test.rb +470 -0
  71. data/test/window_test.rb +26 -0
  72. metadata +37 -2
@@ -1,8 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+ require "open3"
5
+
1
6
  module RuVim
2
7
  class GlobalCommands
3
8
  include Singleton
4
9
 
5
- def call(spec_call, ctx, argv: [], kwargs: {}, bang: false, count: 1)
10
+ def call(spec_call, ctx, argv: [], kwargs: {}, bang: false, count: nil)
6
11
  case spec_call
7
12
  when Symbol, String
8
13
  public_send(spec_call.to_sym, ctx, argv: argv, kwargs: kwargs, bang: bang, count: count)
@@ -12,19 +17,19 @@ module RuVim
12
17
  end
13
18
 
14
19
  def cursor_left(ctx, count:, **)
15
- ctx.window.move_left(ctx.buffer, count)
20
+ move_cursor_horizontally(ctx, direction: :left, count:)
16
21
  end
17
22
 
18
23
  def cursor_right(ctx, count:, **)
19
- ctx.window.move_right(ctx.buffer, count)
24
+ move_cursor_horizontally(ctx, direction: :right, count:)
20
25
  end
21
26
 
22
27
  def cursor_up(ctx, count:, **)
23
- ctx.window.move_up(ctx.buffer, count)
28
+ ctx.window.move_up(ctx.buffer, normalized_count(count))
24
29
  end
25
30
 
26
31
  def cursor_down(ctx, count:, **)
27
- ctx.window.move_down(ctx.buffer, count)
32
+ ctx.window.move_down(ctx.buffer, normalized_count(count))
28
33
  end
29
34
 
30
35
  def cursor_page_up(ctx, kwargs:, count:, **)
@@ -37,6 +42,50 @@ module RuVim
37
42
  ctx.window.move_down(ctx.buffer, page_lines * [count.to_i, 1].max)
38
43
  end
39
44
 
45
+ def cursor_page_up_default(ctx, count:, bang:, **)
46
+ call(:cursor_page_up, ctx, count:, bang:, kwargs: { page_lines: current_page_step_lines(ctx) })
47
+ end
48
+
49
+ def cursor_page_down_default(ctx, count:, bang:, **)
50
+ call(:cursor_page_down, ctx, count:, bang:, kwargs: { page_lines: current_page_step_lines(ctx) })
51
+ end
52
+
53
+ def cursor_page_up_half(ctx, count:, bang:, **)
54
+ call(:cursor_page_up, ctx, count:, bang:, kwargs: { page_lines: current_half_page_step_lines(ctx) })
55
+ end
56
+
57
+ def cursor_page_down_half(ctx, count:, bang:, **)
58
+ call(:cursor_page_down, ctx, count:, bang:, kwargs: { page_lines: current_half_page_step_lines(ctx) })
59
+ end
60
+
61
+ def window_scroll_up(ctx, kwargs:, count:, **)
62
+ scroll_window_vertically(ctx, direction: :up, lines: kwargs[:lines] || kwargs["lines"], view_height: kwargs[:view_height] || kwargs["view_height"], count:)
63
+ end
64
+
65
+ def window_scroll_down(ctx, kwargs:, count:, **)
66
+ scroll_window_vertically(ctx, direction: :down, lines: kwargs[:lines] || kwargs["lines"], view_height: kwargs[:view_height] || kwargs["view_height"], count:)
67
+ end
68
+
69
+ def window_scroll_up_line(ctx, count:, bang:, **)
70
+ call(:window_scroll_up, ctx, count:, bang:, kwargs: { lines: 1, view_height: current_view_height(ctx) + 1 })
71
+ end
72
+
73
+ def window_scroll_down_line(ctx, count:, bang:, **)
74
+ call(:window_scroll_down, ctx, count:, bang:, kwargs: { lines: 1, view_height: current_view_height(ctx) + 1 })
75
+ end
76
+
77
+ def window_cursor_line_top(ctx, count:, **)
78
+ place_cursor_line_in_window(ctx, where: :top, count:)
79
+ end
80
+
81
+ def window_cursor_line_center(ctx, count:, **)
82
+ place_cursor_line_in_window(ctx, where: :center, count:)
83
+ end
84
+
85
+ def window_cursor_line_bottom(ctx, count:, **)
86
+ place_cursor_line_in_window(ctx, where: :bottom, count:)
87
+ end
88
+
40
89
  def cursor_line_start(ctx, **)
41
90
  ctx.window.cursor_x = 0
42
91
  ctx.window.clamp_to_buffer(ctx.buffer)
@@ -56,7 +105,7 @@ module RuVim
56
105
 
57
106
  def cursor_buffer_start(ctx, count:, **)
58
107
  record_jump(ctx)
59
- target_row = [count.to_i - 1, 0].max
108
+ target_row = [normalized_count(count).to_i - 1, 0].max
60
109
  target_row = [target_row, ctx.buffer.line_count - 1].min
61
110
  ctx.window.cursor_y = target_row
62
111
  cursor_first_nonblank(ctx)
@@ -64,11 +113,7 @@ module RuVim
64
113
 
65
114
  def cursor_buffer_end(ctx, count:, **)
66
115
  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
116
+ target_row = count.nil? ? (ctx.buffer.line_count - 1) : [normalized_count(count) - 1, ctx.buffer.line_count - 1].min
72
117
  ctx.window.cursor_y = target_row
73
118
  cursor_first_nonblank(ctx)
74
119
  end
@@ -121,6 +166,7 @@ module RuVim
121
166
 
122
167
  def enter_insert_mode(ctx, **)
123
168
  materialize_intro_buffer_if_needed(ctx)
169
+ ensure_modifiable_for_insert!(ctx)
124
170
  ctx.buffer.begin_change_group
125
171
  ctx.editor.enter_insert_mode
126
172
  ctx.editor.echo("-- INSERT --")
@@ -145,10 +191,12 @@ module RuVim
145
191
 
146
192
  def open_line_below(ctx, **)
147
193
  materialize_intro_buffer_if_needed(ctx)
194
+ ensure_modifiable_for_insert!(ctx)
148
195
  y = ctx.window.cursor_y
149
196
  x = ctx.buffer.line_length(y)
150
197
  ctx.buffer.begin_change_group
151
198
  new_y, new_x = ctx.buffer.insert_newline(y, x)
199
+ new_x = apply_autoindent_to_newline(ctx, row: new_y, previous_row: y, start_col: new_x)
152
200
  ctx.window.cursor_y = new_y
153
201
  ctx.window.cursor_x = new_x
154
202
  ctx.editor.enter_insert_mode
@@ -157,10 +205,13 @@ module RuVim
157
205
 
158
206
  def open_line_above(ctx, **)
159
207
  materialize_intro_buffer_if_needed(ctx)
208
+ ensure_modifiable_for_insert!(ctx)
160
209
  y = ctx.window.cursor_y
161
210
  ctx.buffer.begin_change_group
162
- ctx.buffer.insert_newline(y, 0)
163
- ctx.window.cursor_x = 0
211
+ _new_y, new_x = ctx.buffer.insert_newline(y, 0)
212
+ new_x = apply_autoindent_to_newline(ctx, row: y, previous_row: y + 1, start_col: 0)
213
+ ctx.window.cursor_y = y
214
+ ctx.window.cursor_x = new_x
164
215
  ctx.editor.enter_insert_mode
165
216
  ctx.editor.echo("-- INSERT --")
166
217
  end
@@ -181,12 +232,14 @@ module RuVim
181
232
  end
182
233
 
183
234
  def window_split(ctx, **)
184
- ctx.editor.split_current_window(layout: :horizontal)
235
+ place = ctx.editor.effective_option("splitbelow", window: ctx.window, buffer: ctx.buffer) ? :after : :before
236
+ ctx.editor.split_current_window(layout: :horizontal, place:)
185
237
  ctx.editor.echo("split")
186
238
  end
187
239
 
188
240
  def window_vsplit(ctx, **)
189
- ctx.editor.split_current_window(layout: :vertical)
241
+ place = ctx.editor.effective_option("splitright", window: ctx.window, buffer: ctx.buffer) ? :after : :before
242
+ ctx.editor.split_current_window(layout: :vertical, place:)
190
243
  ctx.editor.echo("vsplit")
191
244
  end
192
245
 
@@ -210,30 +263,83 @@ module RuVim
210
263
  ctx.editor.focus_window_direction(:down)
211
264
  end
212
265
 
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
266
+ def window_focus_or_split_left(ctx, **)
267
+ ed = ctx.editor
268
+ if ed.has_split_ancestor_on_axis?(:left)
269
+ ed.focus_window_direction(:left)
270
+ else
271
+ ed.split_current_window(layout: :vertical, place: :before)
272
+ end
273
+ end
274
+
275
+ def window_focus_or_split_right(ctx, **)
276
+ ed = ctx.editor
277
+ if ed.has_split_ancestor_on_axis?(:right)
278
+ ed.focus_window_direction(:right)
279
+ else
280
+ ed.split_current_window(layout: :vertical, place: :after)
218
281
  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]'}")
282
+ end
283
+
284
+ def window_focus_or_split_up(ctx, **)
285
+ ed = ctx.editor
286
+ if ed.has_split_ancestor_on_axis?(:up)
287
+ ed.focus_window_direction(:up)
223
288
  else
224
- ctx.editor.echo("tab #{ctx.editor.current_tabpage_number}/#{ctx.editor.tabpage_count}")
289
+ ed.split_current_window(layout: :horizontal, place: :before)
290
+ end
291
+ end
292
+
293
+ def window_focus_or_split_down(ctx, **)
294
+ ed = ctx.editor
295
+ if ed.has_split_ancestor_on_axis?(:down)
296
+ ed.focus_window_direction(:down)
297
+ else
298
+ ed.split_current_window(layout: :horizontal, place: :after)
299
+ end
300
+ end
301
+
302
+ def tab_new(ctx, argv:, **)
303
+ path = argv[0]
304
+ if ctx.buffer.modified? && !ctx.editor.effective_option("hidden", window: ctx.window, buffer: ctx.buffer)
305
+ unless maybe_autowrite_before_switch(ctx)
306
+ ctx.editor.echo_error("Unsaved changes (use :w or :q!)")
307
+ return
308
+ end
225
309
  end
226
- tab
310
+ ctx.editor.tabnew(path: path)
227
311
  end
228
312
 
229
313
  def tab_next(ctx, count:, **)
314
+ count = normalized_count(count)
230
315
  ctx.editor.tabnext(count)
231
- ctx.editor.echo("tab #{ctx.editor.current_tabpage_number}/#{ctx.editor.tabpage_count}")
232
316
  end
233
317
 
234
318
  def tab_prev(ctx, count:, **)
319
+ count = normalized_count(count)
235
320
  ctx.editor.tabprev(count)
236
- ctx.editor.echo("tab #{ctx.editor.current_tabpage_number}/#{ctx.editor.tabpage_count}")
321
+ end
322
+
323
+ def tab_list(ctx, **)
324
+ editor = ctx.editor
325
+ items = []
326
+ # For current tab, use live window_order; for others, use saved snapshot
327
+ editor.tabpages.each_with_index do |tab, i|
328
+ is_current = (i == editor.current_tabpage_index)
329
+ current_marker = is_current ? ">" : " "
330
+ items << "#{current_marker}Tab page #{i + 1}"
331
+ win_ids = is_current ? editor.window_order : editor.tabpage_windows(tab)
332
+ win_ids.each do |wid|
333
+ win = editor.windows[wid]
334
+ next unless win
335
+ buf = editor.buffers[win.buffer_id]
336
+ next unless buf
337
+ active = (is_current && wid == editor.current_window_id) ? ">" : " "
338
+ name = buf.display_name
339
+ items << " #{active} #{name}"
340
+ end
341
+ end
342
+ editor.echo_multiline(items)
237
343
  end
238
344
 
239
345
  def enter_command_line_mode(ctx, **)
@@ -253,6 +359,7 @@ module RuVim
253
359
 
254
360
  def delete_char(ctx, count:, **)
255
361
  materialize_intro_buffer_if_needed(ctx)
362
+ count = normalized_count(count)
256
363
  ctx.buffer.begin_change_group
257
364
  deleted = +""
258
365
  count.times do
@@ -266,8 +373,80 @@ module RuVim
266
373
  ctx.window.clamp_to_buffer(ctx.buffer)
267
374
  end
268
375
 
376
+ def substitute_char(ctx, count:, bang:, **)
377
+ call(:change_motion, ctx, count:, bang:, kwargs: { motion: "l" })
378
+ end
379
+
380
+ def swapcase_char(ctx, count:, **)
381
+ materialize_intro_buffer_if_needed(ctx)
382
+ count = normalized_count(count)
383
+
384
+ y = ctx.window.cursor_y
385
+ x = ctx.window.cursor_x
386
+ processed = false
387
+
388
+ ctx.buffer.begin_change_group
389
+ count.times do
390
+ line = ctx.buffer.line_at(y)
391
+ break if x >= line.length
392
+
393
+ ch = line[x]
394
+ swapped = ch.to_s.swapcase
395
+ if !swapped.empty? && swapped != ch
396
+ ctx.buffer.delete_span(y, x, y, x + 1)
397
+ ctx.buffer.insert_char(y, x, swapped[0])
398
+ end
399
+ processed = true
400
+ x += 1
401
+ end
402
+ ctx.buffer.end_change_group
403
+
404
+ ctx.window.cursor_y = y
405
+ ctx.window.cursor_x = processed ? x : ctx.window.cursor_x
406
+ ctx.window.clamp_to_buffer(ctx.buffer)
407
+ end
408
+
409
+ def join_lines(ctx, count:, **)
410
+ materialize_intro_buffer_if_needed(ctx)
411
+ joins = [normalized_count(count) - 1, 1].max
412
+ y = ctx.window.cursor_y
413
+ x = ctx.window.cursor_x
414
+ changed = false
415
+
416
+ ctx.buffer.begin_change_group
417
+ joins.times do
418
+ break if y >= ctx.buffer.line_count - 1
419
+
420
+ left = ctx.buffer.line_at(y)
421
+ right = ctx.buffer.line_at(y + 1)
422
+ join_col = left.length
423
+
424
+ # Join raw lines first.
425
+ break unless ctx.buffer.delete_char(y, join_col)
426
+
427
+ right_trimmed = right.sub(/\A\s+/, "")
428
+ trimmed_count = right.length - right_trimmed.length
429
+ if trimmed_count.positive?
430
+ ctx.buffer.delete_span(y, join_col, y, join_col + trimmed_count)
431
+ end
432
+
433
+ need_space = !left.empty? && !left.match?(/\s\z/) && !right_trimmed.empty? && !right_trimmed.match?(/\A\s/)
434
+ ctx.buffer.insert_char(y, join_col, " ") if need_space
435
+
436
+ x = join_col
437
+ changed = true
438
+ end
439
+ ctx.buffer.end_change_group
440
+
441
+ ctx.window.cursor_y = y
442
+ ctx.window.cursor_x = x
443
+ ctx.window.clamp_to_buffer(ctx.buffer)
444
+ ctx.editor.echo("joined") if changed
445
+ end
446
+
269
447
  def delete_line(ctx, count:, **)
270
448
  materialize_intro_buffer_if_needed(ctx)
449
+ count = normalized_count(count)
271
450
  ctx.buffer.begin_change_group
272
451
  deleted_lines = []
273
452
  count.times { deleted_lines << ctx.buffer.delete_line(ctx.window.cursor_y) }
@@ -279,14 +458,15 @@ module RuVim
279
458
  def delete_motion(ctx, count:, kwargs:, **)
280
459
  materialize_intro_buffer_if_needed(ctx)
281
460
  motion = (kwargs[:motion] || kwargs["motion"]).to_s
461
+ ncount = normalized_count(count)
282
462
  handled =
283
463
  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)
464
+ when "h" then delete_chars_left(ctx, ncount)
465
+ when "l" then delete_chars_right(ctx, ncount)
466
+ when "j" then delete_lines_down(ctx, ncount)
467
+ when "k" then delete_lines_up(ctx, ncount)
288
468
  when "$" then delete_to_end_of_line(ctx)
289
- when "w" then delete_word_forward(ctx, count)
469
+ when "w" then delete_word_forward(ctx, ncount)
290
470
  when "iw" then delete_text_object_word(ctx, around: false)
291
471
  when "aw" then delete_text_object_word(ctx, around: true)
292
472
  else
@@ -391,6 +571,7 @@ module RuVim
391
571
 
392
572
  def replace_char(ctx, argv:, count:, **)
393
573
  materialize_intro_buffer_if_needed(ctx)
574
+ count = normalized_count(count)
394
575
  ch = argv[0].to_s
395
576
  raise RuVim::CommandError, "replace requires a character" if ch.empty?
396
577
 
@@ -411,6 +592,7 @@ module RuVim
411
592
  end
412
593
 
413
594
  def yank_line(ctx, count:, **)
595
+ count = normalized_count(count)
414
596
  start = ctx.window.cursor_y
415
597
  text = ctx.buffer.line_block_text(start, count)
416
598
  store_yank_register(ctx, text:, type: :linewise)
@@ -423,7 +605,7 @@ module RuVim
423
605
  when "w"
424
606
  y = ctx.window.cursor_y
425
607
  x = ctx.window.cursor_x
426
- target = advance_word_forward(ctx.buffer, y, x, count)
608
+ target = advance_word_forward(ctx.buffer, y, x, count, editor: ctx.editor, window: ctx.window)
427
609
  target ||= { row: y, col: x }
428
610
  text = ctx.buffer.span_text(y, x, target[:row], target[:col])
429
611
  store_yank_register(ctx, text:, type: :charwise)
@@ -515,6 +697,45 @@ module RuVim
515
697
  ctx.editor.enter_normal_mode
516
698
  end
517
699
 
700
+ def indent_lines(ctx, count:, **)
701
+ count = normalized_count(count)
702
+ start_row = ctx.window.cursor_y
703
+ end_row = [start_row + count - 1, ctx.buffer.line_count - 1].min
704
+ reindent_range(ctx, start_row, end_row)
705
+ end
706
+
707
+ def indent_motion(ctx, count:, kwargs:, **)
708
+ motion = (kwargs[:motion] || kwargs["motion"]).to_s
709
+ ncount = normalized_count(count)
710
+ start_row = ctx.window.cursor_y
711
+ case motion
712
+ when "j"
713
+ end_row = [start_row + ncount, ctx.buffer.line_count - 1].min
714
+ when "k"
715
+ end_row = start_row
716
+ start_row = [start_row - ncount, 0].max
717
+ when "G"
718
+ end_row = ctx.buffer.line_count - 1
719
+ when "gg"
720
+ end_row = start_row
721
+ start_row = 0
722
+ else
723
+ ctx.editor.echo("Unsupported motion for =: #{motion}")
724
+ return
725
+ end
726
+ reindent_range(ctx, start_row, end_row)
727
+ end
728
+
729
+ def visual_indent(ctx, **)
730
+ sel = ctx.editor.visual_selection
731
+ return unless sel
732
+
733
+ start_row = sel[:start_row]
734
+ end_row = sel[:end_row]
735
+ reindent_range(ctx, start_row, end_row)
736
+ ctx.editor.enter_normal_mode
737
+ end
738
+
518
739
  def visual_select_text_object(ctx, kwargs:, **)
519
740
  motion = (kwargs[:motion] || kwargs["motion"]).to_s
520
741
  span = text_object_span(ctx.buffer, ctx.window, motion)
@@ -540,6 +761,9 @@ module RuVim
540
761
  size = File.exist?(target) ? File.size(target) : 0
541
762
  suffix = bang ? " (force accepted)" : ""
542
763
  ctx.editor.echo("\"#{target}\" #{ctx.buffer.line_count}L, #{size}B written#{suffix}")
764
+ if ctx.editor.get_option("onsavehook")
765
+ ctx.buffer.lang_module.on_save(ctx, target)
766
+ end
543
767
  end
544
768
 
545
769
  def app_quit(ctx, bang:, **)
@@ -563,6 +787,25 @@ module RuVim
563
787
  ctx.editor.request_quit!
564
788
  end
565
789
 
790
+ def app_quit_all(ctx, bang:, **)
791
+ unless bang
792
+ modified = ctx.editor.buffers.values.select { |b| b.file_buffer? && b.modified? }
793
+ unless modified.empty?
794
+ ctx.editor.echo_error("#{modified.size} buffer(s) have unsaved changes (add ! to override)")
795
+ return
796
+ end
797
+ end
798
+ ctx.editor.request_quit!
799
+ end
800
+
801
+ def file_write_quit_all(ctx, bang:, **)
802
+ ctx.editor.buffers.each_value do |buf|
803
+ next unless buf.file_buffer? && buf.modified? && buf.path
804
+ buf.write_to(buf.path)
805
+ end
806
+ app_quit_all(ctx, bang: true)
807
+ end
808
+
566
809
  def file_write_quit(ctx, argv:, bang:, **)
567
810
  file_write(ctx, argv:, bang:)
568
811
  return unless ctx.editor.running?
@@ -587,13 +830,42 @@ module RuVim
587
830
  end
588
831
 
589
832
  if ctx.buffer.modified? && !bang
590
- ctx.editor.echo_error("Unsaved changes (use :e! to discard and open)")
833
+ if ctx.editor.effective_option("hidden", window: ctx.window, buffer: ctx.buffer)
834
+ # hidden permits abandoning a modified buffer without forcing write.
835
+ elsif maybe_autowrite_before_switch(ctx)
836
+ # autowrite handled
837
+ else
838
+ ctx.editor.echo_error("Unsaved changes (use :e! to discard and open)")
839
+ return
840
+ end
841
+ end
842
+
843
+ ctx.editor.open_path(path)
844
+ end
845
+
846
+ def file_goto_under_cursor(ctx, **)
847
+ token = file_token_under_cursor(ctx.buffer, ctx.window)
848
+ if token.nil? || token.empty?
849
+ ctx.editor.echo_error("No file under cursor")
591
850
  return
592
851
  end
593
852
 
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]")
853
+ target = parse_gf_target(token)
854
+ path = resolve_gf_path(ctx, target[:path])
855
+ unless path
856
+ ctx.editor.echo_error("File not found: #{target[:path]}")
857
+ return
858
+ end
859
+
860
+ if ctx.buffer.modified? && !ctx.editor.effective_option("hidden", window: ctx.window, buffer: ctx.buffer)
861
+ unless maybe_autowrite_before_switch(ctx)
862
+ ctx.editor.echo_error("Unsaved changes (set hidden or :w)")
863
+ return
864
+ end
865
+ end
866
+
867
+ ctx.editor.open_path(path)
868
+ move_cursor_to_gf_line(ctx, target[:line]) if target[:line]
597
869
  end
598
870
 
599
871
  def buffer_list(ctx, **)
@@ -601,23 +873,28 @@ module RuVim
601
873
  alt_id = ctx.editor.alternate_buffer_id
602
874
  items = ctx.editor.buffer_ids.map do |id|
603
875
  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}"
876
+ indicator = id == current_id ? "%a" : " "
877
+ indicator = "# " if id == alt_id && id != current_id
878
+ mod = b.modified? ? "+" : " "
879
+ name = b.path ? "\"#{b.path}\"" : "[No Name]"
880
+ line_info = "line #{b.respond_to?(:cursor_line) ? b.cursor_line : 0}"
881
+ # Find the window showing this buffer to get cursor line
882
+ win = ctx.editor.windows.values.find { |w| w.buffer_id == id }
883
+ line_info = "line #{win ? win.cursor_y + 1 : 0}"
884
+ "%3d %s %s %-30s %s" % [id, indicator, mod, name, line_info]
610
885
  end
611
- ctx.editor.echo(items.join(" | "))
886
+ ctx.editor.echo_multiline(items)
612
887
  end
613
888
 
614
889
  def buffer_next(ctx, count:, bang:, **)
890
+ count = normalized_count(count)
615
891
  target = ctx.editor.current_buffer.id
616
892
  count.times { target = ctx.editor.next_buffer_id_from(target, 1) }
617
893
  switch_buffer_id(ctx, target, bang:)
618
894
  end
619
895
 
620
896
  def buffer_prev(ctx, count:, bang:, **)
897
+ count = normalized_count(count)
621
898
  target = ctx.editor.current_buffer.id
622
899
  count.times { target = ctx.editor.next_buffer_id_from(target, -1) }
623
900
  switch_buffer_id(ctx, target, bang:)
@@ -639,6 +916,28 @@ module RuVim
639
916
  switch_buffer_id(ctx, target_id, bang:)
640
917
  end
641
918
 
919
+ def buffer_delete(ctx, argv:, bang:, **)
920
+ arg = argv[0]
921
+ target_id =
922
+ if arg.nil? || arg.empty?
923
+ ctx.buffer.id
924
+ elsif arg == "#"
925
+ ctx.editor.alternate_buffer_id || raise(RuVim::CommandError, "No alternate buffer")
926
+ elsif arg.match?(/\A\d+\z/)
927
+ arg.to_i
928
+ else
929
+ find_buffer_by_name(ctx.editor, arg)&.id || raise(RuVim::CommandError, "No such buffer: #{arg}")
930
+ end
931
+
932
+ target = ctx.editor.buffers[target_id] || raise(RuVim::CommandError, "No such buffer: #{target_id}")
933
+ if target.modified? && !bang
934
+ raise RuVim::CommandError, "No write since last change (use :bdelete! to discard)"
935
+ end
936
+
937
+ ctx.editor.delete_buffer(target_id)
938
+ ctx.editor.echo("buffer #{target_id} deleted")
939
+ end
940
+
642
941
  def ex_help(ctx, argv: [], **)
643
942
  topic = argv.first.to_s
644
943
  registry = RuVim::ExCommandRegistry.instance
@@ -675,7 +974,7 @@ module RuVim
675
974
  when "config"
676
975
  "Config: XDG Ruby DSL at ~/.config/ruvim/init.rb and ftplugin/<filetype>.rb"
677
976
  when "bindings", "keys", "keymap"
678
- "Bindings: see docs/binding.md. Ex complement: Tab, insert completion: Ctrl-n/Ctrl-p"
977
+ "Bindings: use :bindings (current effective key bindings by layer). Docs: docs/binding.md"
679
978
  when "number", "relativenumber", "ignorecase", "smartcase", "hlsearch", "tabstop", "filetype"
680
979
  option_help_line(key)
681
980
  else
@@ -691,8 +990,17 @@ module RuVim
691
990
  def ex_define_command(ctx, argv:, bang:, **)
692
991
  registry = RuVim::ExCommandRegistry.instance
693
992
  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(', ')}")
993
+ user_cmds = registry.all.select { |spec| spec.source == :user }
994
+ if user_cmds.empty?
995
+ ctx.editor.echo("No user commands")
996
+ else
997
+ header = " Name Definition"
998
+ items = [header] + user_cmds.map { |spec|
999
+ body = spec.respond_to?(:body) ? spec.body.to_s : spec.name.to_s
1000
+ " %-12s%s" % [spec.name, body]
1001
+ }
1002
+ ctx.editor.echo_multiline(items)
1003
+ end
696
1004
  return
697
1005
  end
698
1006
 
@@ -724,24 +1032,128 @@ module RuVim
724
1032
  raise RuVim::CommandError, "Usage: :ruby <code>" if code.strip.empty?
725
1033
 
726
1034
  b = binding
1035
+ # Use local_variable_set for eval locals to avoid "assigned but unused variable"
1036
+ # warnings while still exposing editor/buffer/window in :ruby.
727
1037
  b.local_variable_set(:editor, ctx.editor)
728
1038
  b.local_variable_set(:buffer, ctx.buffer)
729
1039
  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}")
1040
+ saved_stdout = STDOUT.dup
1041
+ saved_stderr = STDERR.dup
1042
+ original_g_stdout = $stdout
1043
+ original_g_stderr = $stderr
1044
+ result = nil
1045
+ stdout_text = ""
1046
+ stderr_text = ""
1047
+ Tempfile.create("ruvim-ruby-stdout") do |outf|
1048
+ Tempfile.create("ruvim-ruby-stderr") do |errf|
1049
+ STDOUT.reopen(outf)
1050
+ STDERR.reopen(errf)
1051
+ $stdout = STDOUT
1052
+ $stderr = STDERR
1053
+ result = eval(code, b) # rubocop:disable Security/Eval
1054
+ STDOUT.flush
1055
+ STDERR.flush
1056
+ outf.flush
1057
+ errf.flush
1058
+ outf.rewind
1059
+ errf.rewind
1060
+ stdout_text = outf.read.to_s
1061
+ stderr_text = errf.read.to_s
1062
+ end
1063
+ end
1064
+ if !stdout_text.empty? || !stderr_text.empty?
1065
+ lines = ["Ruby output", ""]
1066
+ unless stdout_text.empty?
1067
+ lines << "[stdout]"
1068
+ lines.concat(stdout_text.lines(chomp: true))
1069
+ lines << ""
1070
+ end
1071
+ unless stderr_text.empty?
1072
+ lines << "[stderr]"
1073
+ lines.concat(stderr_text.lines(chomp: true))
1074
+ lines << ""
1075
+ end
1076
+ lines << "[result]"
1077
+ lines << (result.nil? ? "nil" : result.inspect)
1078
+ ctx.editor.show_help_buffer!(title: "[Ruby Output]", lines:, filetype: "ruby")
1079
+ else
1080
+ ctx.editor.echo(result.nil? ? "ruby: nil" : "ruby: #{result.inspect}")
1081
+ end
732
1082
  rescue StandardError => e
733
1083
  raise RuVim::CommandError, "Ruby error: #{e.class}: #{e.message}"
1084
+ ensure
1085
+ if defined?(saved_stdout) && saved_stdout
1086
+ STDOUT.reopen(saved_stdout)
1087
+ saved_stdout.close unless saved_stdout.closed?
1088
+ end
1089
+ if defined?(saved_stderr) && saved_stderr
1090
+ STDERR.reopen(saved_stderr)
1091
+ saved_stderr.close unless saved_stderr.closed?
1092
+ end
1093
+ $stdout = (defined?(original_g_stdout) && original_g_stdout) ? original_g_stdout : STDOUT
1094
+ $stderr = (defined?(original_g_stderr) && original_g_stderr) ? original_g_stderr : STDERR
1095
+ end
1096
+
1097
+ def ex_shell(ctx, command:, **)
1098
+ raise RuVim::CommandError, "Restricted mode: :! is disabled" if ctx.editor.respond_to?(:restricted_mode?) && ctx.editor.restricted_mode?
1099
+
1100
+ cmd = command.to_s
1101
+ raise RuVim::CommandError, "Usage: :!<command>" if cmd.strip.empty?
1102
+
1103
+ shell = ENV["SHELL"].to_s
1104
+ shell = "/bin/sh" if shell.empty?
1105
+ stdout_text, stderr_text, status = Open3.capture3(shell, "-c", cmd)
1106
+
1107
+ if !stdout_text.to_s.empty? || !stderr_text.to_s.empty?
1108
+ lines = ["Shell output", "", "[command]", cmd, ""]
1109
+ unless stdout_text.to_s.empty?
1110
+ lines << "[stdout]"
1111
+ lines.concat(stdout_text.to_s.lines(chomp: true))
1112
+ lines << ""
1113
+ end
1114
+ unless stderr_text.to_s.empty?
1115
+ lines << "[stderr]"
1116
+ lines.concat(stderr_text.to_s.lines(chomp: true))
1117
+ lines << ""
1118
+ end
1119
+ lines << "[status]"
1120
+ lines << "exit #{status.exitstatus}"
1121
+ ctx.editor.show_help_buffer!(title: "[Shell Output]", lines:, filetype: "sh")
1122
+ else
1123
+ ctx.editor.echo("shell exit #{status.exitstatus}")
1124
+ end
1125
+ rescue Errno::ENOENT => e
1126
+ raise RuVim::CommandError, "Shell error: #{e.message}"
734
1127
  end
735
1128
 
736
1129
  def ex_commands(ctx, **)
737
- items = RuVim::ExCommandRegistry.instance.all.map do |spec|
1130
+ rows = RuVim::ExCommandRegistry.instance.all.map do |spec|
738
1131
  alias_text = spec.aliases.empty? ? "" : " (#{spec.aliases.join(', ')})"
739
1132
  source = spec.source == :user ? " [user]" : ""
740
- "#{spec.name}#{alias_text}#{source}"
1133
+ name = "#{spec.name}#{alias_text}#{source}"
1134
+ desc = spec.desc.to_s
1135
+ keys = ex_command_binding_labels(ctx.editor, spec)
1136
+ [name, desc, keys]
1137
+ end
1138
+ name_width = rows.map { |name, _desc, _keys| name.length }.max || 0
1139
+ items = rows.map do |name, desc, keys|
1140
+ line = "#{name.ljust(name_width)} #{desc}"
1141
+ line += " keys: #{keys.join(', ')}" unless keys.empty?
1142
+ line
741
1143
  end
742
1144
  ctx.editor.show_help_buffer!(title: "[Commands]", lines: ["Ex commands", "", *items])
743
1145
  end
744
1146
 
1147
+ def ex_bindings(ctx, argv: [], **)
1148
+ keymaps = ctx.editor.keymap_manager
1149
+ raise RuVim::CommandError, "Keymap manager is unavailable" unless keymaps
1150
+
1151
+ mode_filter, sort = parse_bindings_args(argv)
1152
+ entries = keymaps.binding_entries_for_context(ctx.editor, mode: mode_filter)
1153
+ lines = bindings_buffer_lines(ctx.editor, entries, mode_filter:, sort:)
1154
+ ctx.editor.show_help_buffer!(title: "[Bindings]", lines:)
1155
+ end
1156
+
745
1157
  def ex_set(ctx, argv:, **)
746
1158
  ex_set_common(ctx, argv, scope: :auto)
747
1159
  end
@@ -764,6 +1176,7 @@ module RuVim
764
1176
  end
765
1177
 
766
1178
  ctx.editor.set_quickfix_list(items)
1179
+ ctx.editor.select_quickfix(0)
767
1180
  ctx.editor.jump_to_location(ctx.editor.current_quickfix_item)
768
1181
  ctx.editor.echo("quickfix: #{items.length} item(s)")
769
1182
  end
@@ -778,12 +1191,13 @@ module RuVim
778
1191
  end
779
1192
 
780
1193
  ctx.editor.set_location_list(items, window_id: ctx.window.id)
1194
+ ctx.editor.select_location_list(0, window_id: ctx.window.id)
781
1195
  ctx.editor.jump_to_location(ctx.editor.current_location_list_item(ctx.window.id))
782
1196
  ctx.editor.echo("location list: #{items.length} item(s)")
783
1197
  end
784
1198
 
785
1199
  def ex_copen(ctx, **)
786
- open_list_window(ctx, kind: :quickfix, title: "[Quickfix]", lines: quickfix_buffer_lines(ctx.editor))
1200
+ open_list_window(ctx, kind: :quickfix, title: "[Quickfix]", lines: quickfix_buffer_lines(ctx.editor), source_window_id: ctx.window.id)
787
1201
  end
788
1202
 
789
1203
  def ex_cclose(ctx, **)
@@ -797,6 +1211,7 @@ module RuVim
797
1211
  return
798
1212
  end
799
1213
  ctx.editor.jump_to_location(item)
1214
+ refresh_list_window(ctx.editor, :quickfix)
800
1215
  ctx.editor.echo(quickfix_item_echo(ctx.editor))
801
1216
  end
802
1217
 
@@ -807,11 +1222,13 @@ module RuVim
807
1222
  return
808
1223
  end
809
1224
  ctx.editor.jump_to_location(item)
1225
+ refresh_list_window(ctx.editor, :quickfix)
810
1226
  ctx.editor.echo(quickfix_item_echo(ctx.editor))
811
1227
  end
812
1228
 
813
1229
  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))
1230
+ open_list_window(ctx, kind: :location_list, title: "[Location List]", lines: location_list_buffer_lines(ctx.editor, ctx.window.id),
1231
+ source_window_id: ctx.window.id)
815
1232
  end
816
1233
 
817
1234
  def ex_lclose(ctx, **)
@@ -825,6 +1242,7 @@ module RuVim
825
1242
  return
826
1243
  end
827
1244
  ctx.editor.jump_to_location(item)
1245
+ refresh_list_window(ctx.editor, :location_list)
828
1246
  ctx.editor.echo(location_item_echo(ctx.editor, ctx.window.id))
829
1247
  end
830
1248
 
@@ -835,37 +1253,89 @@ module RuVim
835
1253
  return
836
1254
  end
837
1255
  ctx.editor.jump_to_location(item)
1256
+ refresh_list_window(ctx.editor, :location_list)
838
1257
  ctx.editor.echo(location_item_echo(ctx.editor, ctx.window.id))
839
1258
  end
840
1259
 
841
- def ex_substitute(ctx, pattern:, replacement:, global: false, **)
1260
+ def ex_substitute(ctx, pattern:, replacement:, flags_str: nil, range_start: nil, range_end: nil, global: false, **)
842
1261
  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
1262
+ flags = parse_substitute_flags(flags_str, default_global: global)
1263
+ raise RuVim::CommandError, "Confirm flag (:s///c) is not yet supported" if flags[:confirm]
1264
+
1265
+ regex = build_substitute_regex(pattern, flags, ctx)
1266
+
1267
+ r_start = range_start || 0
1268
+ r_end = range_end || (ctx.buffer.line_count - 1)
1269
+
1270
+ if flags[:count_only]
1271
+ total = count_matches_in_range(ctx.buffer, regex, r_start, r_end, flags[:global])
1272
+ ctx.editor.echo("#{total} match(es)")
1273
+ return
857
1274
  end
858
1275
 
1276
+ changed = substitute_range(ctx, regex, replacement, r_start, r_end, flags)
1277
+
859
1278
  if changed.positive?
860
- ctx.buffer.begin_change_group
861
- ctx.buffer.replace_all_lines!(new_lines)
862
- ctx.buffer.end_change_group
863
1279
  ctx.editor.echo("#{changed} substitution(s)")
1280
+ elsif flags[:no_error]
1281
+ ctx.editor.echo("Pattern not found: #{pattern}")
864
1282
  else
865
1283
  ctx.editor.echo("Pattern not found: #{pattern}")
866
1284
  end
867
1285
  end
868
1286
 
1287
+ def ex_grep(ctx, argv:, kwargs: {}, **)
1288
+ run_external_grep(ctx, argv:, target: :quickfix)
1289
+ end
1290
+
1291
+ def ex_lgrep(ctx, argv:, kwargs: {}, **)
1292
+ run_external_grep(ctx, argv:, target: :location_list)
1293
+ end
1294
+
1295
+ def ex_delete_lines(ctx, kwargs: {}, **)
1296
+ materialize_intro_buffer_if_needed(ctx)
1297
+ r_start = kwargs[:range_start]
1298
+ r_end = kwargs[:range_end]
1299
+ unless r_start && r_end
1300
+ # Default to current line
1301
+ r_start = r_end = ctx.window.cursor_y
1302
+ end
1303
+
1304
+ count = r_end - r_start + 1
1305
+ deleted_text = ctx.buffer.line_block_text(r_start, count)
1306
+ ctx.buffer.begin_change_group
1307
+ count.times { ctx.buffer.delete_line(r_start) }
1308
+ ctx.buffer.end_change_group
1309
+ store_delete_register(ctx, text: deleted_text, type: :linewise)
1310
+ ctx.window.cursor_y = [r_start, ctx.buffer.line_count - 1].min
1311
+ ctx.window.cursor_x = 0
1312
+ ctx.window.clamp_to_buffer(ctx.buffer)
1313
+ ctx.editor.echo("#{count} line(s) deleted")
1314
+ end
1315
+
1316
+ def ex_yank_lines(ctx, kwargs: {}, **)
1317
+ materialize_intro_buffer_if_needed(ctx)
1318
+ r_start = kwargs[:range_start]
1319
+ r_end = kwargs[:range_end]
1320
+ unless r_start && r_end
1321
+ r_start = r_end = ctx.window.cursor_y
1322
+ end
1323
+
1324
+ count = r_end - r_start + 1
1325
+ text = ctx.buffer.line_block_text(r_start, count)
1326
+ store_yank_register(ctx, text:, type: :linewise)
1327
+ ctx.editor.echo("#{count} line(s) yanked")
1328
+ end
1329
+
1330
+ def ex_rich(ctx, argv: [], **)
1331
+ format = argv.first
1332
+ RuVim::RichView.toggle!(ctx.editor, format: format)
1333
+ end
1334
+
1335
+ def rich_toggle(ctx, **)
1336
+ RuVim::RichView.toggle!(ctx.editor)
1337
+ end
1338
+
869
1339
  def submit_search(ctx, pattern:, direction:)
870
1340
  text = pattern.to_s
871
1341
  if text.empty?
@@ -881,6 +1351,35 @@ module RuVim
881
1351
 
882
1352
  private
883
1353
 
1354
+ def reindent_range(ctx, start_row, end_row)
1355
+ buf = ctx.buffer
1356
+ lang_mod = buf.lang_module
1357
+
1358
+ sw = ctx.editor.effective_option("shiftwidth", buffer: buf).to_i
1359
+ sw = 2 if sw <= 0
1360
+
1361
+ buf.begin_change_group
1362
+ (start_row..end_row).each do |row|
1363
+ target_indent = lang_mod.calculate_indent(buf.lines, row, sw)
1364
+ next unless target_indent
1365
+
1366
+ line = buf.line_at(row)
1367
+ current_indent = line[/\A */].to_s.length
1368
+ next if current_indent == target_indent
1369
+
1370
+ buf.delete_span(row, 0, row, current_indent) if current_indent > 0
1371
+ buf.insert_text(row, 0, " " * target_indent) if target_indent > 0
1372
+ end
1373
+ buf.end_change_group
1374
+
1375
+ ctx.window.cursor_y = start_row
1376
+ line = buf.line_at(start_row)
1377
+ ctx.window.cursor_x = (line[/\A */]&.length || 0)
1378
+ ctx.window.clamp_to_buffer(buf)
1379
+ count = end_row - start_row + 1
1380
+ ctx.editor.echo("#{count} line#{"s" if count > 1} indented")
1381
+ end
1382
+
884
1383
  def parse_vimgrep_pattern(argv)
885
1384
  raw = Array(argv).join(" ").strip
886
1385
  raise RuVim::CommandError, "Usage: :vimgrep /pattern/" if raw.empty?
@@ -892,6 +1391,70 @@ module RuVim
892
1391
  end
893
1392
  end
894
1393
 
1394
+ def run_external_grep(ctx, argv:, target:)
1395
+ args = Array(argv).join(" ").strip
1396
+ raise RuVim::CommandError, "Usage: :grep pattern [files...]" if args.empty?
1397
+
1398
+ grepprg = ctx.editor.effective_option("grepprg", window: ctx.window, buffer: ctx.buffer) || "grep -n"
1399
+ cmd = "#{grepprg} #{args}"
1400
+
1401
+ stdout, stderr, status = Open3.capture3(cmd)
1402
+ if stdout.strip.empty? && !status.success?
1403
+ msg = stderr.strip.empty? ? "No matches found" : stderr.strip
1404
+ ctx.editor.echo_error(msg)
1405
+ return
1406
+ end
1407
+
1408
+ items = parse_grep_output(ctx, stdout)
1409
+ if items.empty?
1410
+ ctx.editor.echo_error("No matches found")
1411
+ return
1412
+ end
1413
+
1414
+ case target
1415
+ when :quickfix
1416
+ ctx.editor.set_quickfix_list(items)
1417
+ ctx.editor.select_quickfix(0)
1418
+ ctx.editor.jump_to_location(ctx.editor.current_quickfix_item)
1419
+ ctx.editor.echo("quickfix: #{items.length} item(s)")
1420
+ when :location_list
1421
+ ctx.editor.set_location_list(items, window_id: ctx.window.id)
1422
+ ctx.editor.select_location_list(0, window_id: ctx.window.id)
1423
+ ctx.editor.jump_to_location(ctx.editor.current_location_list_item(ctx.window.id))
1424
+ ctx.editor.echo("location list: #{items.length} item(s)")
1425
+ end
1426
+ end
1427
+
1428
+ def parse_grep_output(ctx, output)
1429
+ items = []
1430
+ output.each_line do |line|
1431
+ line = line.chomp
1432
+ # Parse filename:lineno:text format
1433
+ if (m = line.match(/\A(.+?):(\d+):(.*)?\z/))
1434
+ filepath = m[1]
1435
+ lineno = m[2].to_i - 1 # 0-based
1436
+ text = m[3].to_s
1437
+ buf = ensure_buffer_for_grep_file(ctx, filepath)
1438
+ items << { buffer_id: buf.id, row: lineno, col: 0, text: text }
1439
+ end
1440
+ end
1441
+ items
1442
+ end
1443
+
1444
+ def ensure_buffer_for_grep_file(ctx, filepath)
1445
+ abspath = File.expand_path(filepath)
1446
+ # Check if buffer already exists for this file
1447
+ existing = ctx.editor.buffers.values.find { |b| b.path && File.expand_path(b.path) == abspath }
1448
+ return existing if existing
1449
+
1450
+ # Create buffer for the file
1451
+ if File.exist?(abspath)
1452
+ ctx.editor.add_buffer_from_file(abspath)
1453
+ else
1454
+ ctx.editor.add_empty_buffer(path: abspath)
1455
+ end
1456
+ end
1457
+
895
1458
  def grep_items_for_buffers(buffers, regex)
896
1459
  Array(buffers).flat_map do |buffer|
897
1460
  buffer.lines.each_with_index.flat_map do |line, row|
@@ -933,10 +1496,11 @@ module RuVim
933
1496
  ]
934
1497
  end
935
1498
 
936
- def open_list_window(ctx, kind:, title:, lines:)
1499
+ def open_list_window(ctx, kind:, title:, lines:, source_window_id:)
937
1500
  editor = ctx.editor
938
1501
  editor.split_current_window(layout: :horizontal)
939
1502
  buffer = editor.add_virtual_buffer(kind:, name: title, lines:, filetype: "qf", readonly: true, modifiable: false)
1503
+ buffer.options["ruvim_list_source_window_id"] = source_window_id
940
1504
  editor.switch_to_buffer(buffer.id)
941
1505
  editor.echo(title)
942
1506
  buffer
@@ -956,6 +1520,22 @@ module RuVim
956
1520
  editor.echo("#{kind} closed")
957
1521
  end
958
1522
 
1523
+ def refresh_list_window(editor, kind)
1524
+ wids = editor.find_window_ids_by_buffer_kind(kind)
1525
+ return if wids.empty?
1526
+
1527
+ lines = case kind
1528
+ when :quickfix then quickfix_buffer_lines(editor)
1529
+ when :location_list then location_list_buffer_lines(editor, editor.current_window_id)
1530
+ end
1531
+ wids.each do |wid|
1532
+ buf = editor.buffers[editor.windows[wid].buffer_id]
1533
+ next unless buf
1534
+ # Bypass modifiable check — this is an internal refresh of a readonly list buffer
1535
+ buf.instance_variable_set(:@lines, Array(lines).map(&:dup))
1536
+ end
1537
+ end
1538
+
959
1539
  def quickfix_item_echo(editor)
960
1540
  item = editor.current_quickfix_item
961
1541
  list_item_echo(editor, item, editor.quickfix_index, editor.quickfix_items.length, label: "qf")
@@ -979,9 +1559,11 @@ module RuVim
979
1559
  raise RuVim::CommandError, "No such buffer: #{buffer_id}"
980
1560
  end
981
1561
 
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
1562
+ if ctx.buffer.modified? && ctx.buffer.id != buffer_id && !bang && !ctx.editor.effective_option("hidden", window: ctx.window, buffer: ctx.buffer)
1563
+ unless maybe_autowrite_before_switch(ctx)
1564
+ ctx.editor.echo_error("Unsaved changes (use :w or :buffer! / :bnext! / :bprev!)")
1565
+ return
1566
+ end
985
1567
  end
986
1568
 
987
1569
  record_jump(ctx)
@@ -1017,28 +1599,55 @@ module RuVim
1017
1599
  end.join("\n")
1018
1600
  end
1019
1601
 
1602
+ def apply_autoindent_to_newline(ctx, row:, previous_row:, start_col: 0)
1603
+ return start_col unless ctx.editor.effective_option("autoindent", window: ctx.window, buffer: ctx.buffer)
1604
+
1605
+ prev = ctx.buffer.line_at(previous_row)
1606
+ indent = prev[/\A[ \t]*/].to_s
1607
+
1608
+ if ctx.editor.effective_option("smartindent", window: ctx.window, buffer: ctx.buffer)
1609
+ trimmed = prev.rstrip
1610
+ if trimmed.end_with?("{", "[", "(")
1611
+ sw = ctx.editor.effective_option("shiftwidth", window: ctx.window, buffer: ctx.buffer).to_i
1612
+ sw = 2 if sw <= 0
1613
+ indent += " " * sw
1614
+ end
1615
+ end
1616
+
1617
+ return start_col if indent.empty?
1618
+
1619
+ _y, x = ctx.buffer.insert_text(row, start_col, indent)
1620
+ x
1621
+ end
1622
+
1020
1623
  def search_current_word(ctx, exact:, direction:)
1021
- word = current_word_under_cursor(ctx.buffer, ctx.window)
1624
+ keyword_rx = keyword_char_regex(ctx.editor, ctx.buffer, ctx.window)
1625
+ word = current_word_under_cursor(ctx.buffer, ctx.window, keyword_rx:)
1022
1626
  if word.nil? || word.empty?
1023
1627
  ctx.editor.echo("No word under cursor")
1024
1628
  return
1025
1629
  end
1026
1630
 
1027
- pattern = exact ? "\\b#{Regexp.escape(word)}\\b" : Regexp.escape(word)
1631
+ pattern =
1632
+ if exact
1633
+ "(?<!#{keyword_rx.source})#{Regexp.escape(word)}(?!#{keyword_rx.source})"
1634
+ else
1635
+ Regexp.escape(word)
1636
+ end
1028
1637
  ctx.editor.set_last_search(pattern:, direction:)
1029
1638
  move_to_search(ctx, pattern:, direction:, count: 1)
1030
1639
  end
1031
1640
 
1032
- def current_word_under_cursor(buffer, window)
1641
+ def current_word_under_cursor(buffer, window, keyword_rx: /[[:alnum:]_]/)
1033
1642
  line = buffer.line_at(window.cursor_y)
1034
1643
  return nil if line.empty?
1035
1644
 
1036
1645
  x = [window.cursor_x, line.length - 1].min
1037
1646
  return nil if x.negative?
1038
1647
 
1039
- if line[x] !~ /[[:alnum:]_]/
1648
+ if !keyword_char?(line[x], keyword_rx)
1040
1649
  left = x - 1
1041
- if left >= 0 && line[left] =~ /[[:alnum:]_]/
1650
+ if left >= 0 && keyword_char?(line[left], keyword_rx)
1042
1651
  x = left
1043
1652
  else
1044
1653
  return nil
@@ -1046,9 +1655,9 @@ module RuVim
1046
1655
  end
1047
1656
 
1048
1657
  s = x
1049
- s -= 1 while s.positive? && line[s - 1] =~ /[[:alnum:]_]/
1658
+ s -= 1 while s.positive? && keyword_char?(line[s - 1], keyword_rx)
1050
1659
  e = x + 1
1051
- e += 1 while e < line.length && line[e] =~ /[[:alnum:]_]/
1660
+ e += 1 while e < line.length && keyword_char?(line[e], keyword_rx)
1052
1661
  line[s...e]
1053
1662
  end
1054
1663
 
@@ -1132,7 +1741,7 @@ module RuVim
1132
1741
  def delete_word_forward(ctx, count)
1133
1742
  y = ctx.window.cursor_y
1134
1743
  x = ctx.window.cursor_x
1135
- target = advance_word_forward(ctx.buffer, y, x, count)
1744
+ target = advance_word_forward(ctx.buffer, y, x, count, editor: ctx.editor, window: ctx.window)
1136
1745
  return true unless target
1137
1746
 
1138
1747
  deleted = ctx.buffer.span_text(y, x, target[:row], target[:col])
@@ -1194,12 +1803,14 @@ module RuVim
1194
1803
  true
1195
1804
  end
1196
1805
 
1197
- def advance_word_forward(buffer, row, col, count)
1806
+ def advance_word_forward(buffer, row, col, count, editor: nil, window: nil)
1198
1807
  text = buffer.lines.join("\n")
1199
1808
  flat = cursor_to_offset(buffer, row, col)
1200
1809
  idx = flat
1810
+ keyword_rx = keyword_char_regex(editor, buffer, window)
1811
+ count = normalized_count(count)
1201
1812
  count.times do
1202
- idx = next_word_start_offset(text, idx)
1813
+ idx = next_word_start_offset(text, idx, keyword_rx)
1203
1814
  return nil unless idx
1204
1815
  end
1205
1816
  offset_to_cursor(buffer, idx)
@@ -1209,14 +1820,14 @@ module RuVim
1209
1820
  buffer = ctx.buffer
1210
1821
  row = ctx.window.cursor_y
1211
1822
  col = ctx.window.cursor_x
1212
- count = 1 if count.to_i <= 0
1823
+ count = normalized_count(count)
1213
1824
  target = { row:, col: }
1214
1825
  count.times do
1215
1826
  target =
1216
1827
  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)
1828
+ when :forward_start then advance_word_forward(buffer, target[:row], target[:col], 1, editor: ctx.editor, window: ctx.window)
1829
+ when :backward_start then advance_word_backward(buffer, target[:row], target[:col], 1, editor: ctx.editor, window: ctx.window)
1830
+ when :forward_end then advance_word_end(buffer, target[:row], target[:col], 1, editor: ctx.editor, window: ctx.window)
1220
1831
  end
1221
1832
  break unless target
1222
1833
  end
@@ -1227,60 +1838,70 @@ module RuVim
1227
1838
  ctx.window.clamp_to_buffer(buffer)
1228
1839
  end
1229
1840
 
1230
- def advance_word_backward(buffer, row, col, _count)
1841
+ def advance_word_backward(buffer, row, col, _count, editor: nil, window: nil)
1231
1842
  text = buffer.lines.join("\n")
1232
1843
  idx = cursor_to_offset(buffer, row, col)
1233
1844
  idx = [idx - 1, 0].max
1234
- while idx > 0 && char_class(text[idx]) == :space
1845
+ keyword_rx = keyword_char_regex(editor, buffer, window)
1846
+ while idx > 0 && char_class(text[idx], keyword_rx) == :space
1235
1847
  idx -= 1
1236
1848
  end
1237
- cls = char_class(text[idx])
1238
- while idx > 0 && char_class(text[idx - 1]) == cls && cls != :space
1849
+ cls = char_class(text[idx], keyword_rx)
1850
+ while idx > 0 && char_class(text[idx - 1], keyword_rx) == cls && cls != :space
1239
1851
  idx -= 1
1240
1852
  end
1241
- while idx > 0 && char_class(text[idx]) == :space
1853
+ while idx > 0 && char_class(text[idx], keyword_rx) == :space
1242
1854
  idx += 1
1243
1855
  end
1244
1856
  offset_to_cursor(buffer, idx)
1245
1857
  end
1246
1858
 
1247
- def advance_word_end(buffer, row, col, _count)
1859
+ def advance_word_end(buffer, row, col, _count, editor: nil, window: nil)
1248
1860
  text = buffer.lines.join("\n")
1249
1861
  idx = cursor_to_offset(buffer, row, col)
1250
1862
  n = text.length
1251
- while idx < n && char_class(text[idx]) == :space
1863
+ keyword_rx = keyword_char_regex(editor, buffer, window)
1864
+
1865
+ # Vim-like `e`: if already on the end of a word, move to the next word's end.
1866
+ if idx < n
1867
+ cur_cls = char_class(text[idx], keyword_rx)
1868
+ next_cls = (idx + 1 < n) ? char_class(text[idx + 1], keyword_rx) : nil
1869
+ idx += 1 if cur_cls != :space && next_cls != cur_cls
1870
+ end
1871
+
1872
+ while idx < n && char_class(text[idx], keyword_rx) == :space
1252
1873
  idx += 1
1253
1874
  end
1254
1875
  return nil if idx >= n
1255
1876
 
1256
- cls = char_class(text[idx])
1257
- idx += 1 while idx + 1 < n && char_class(text[idx + 1]) == cls && cls != :space
1877
+ cls = char_class(text[idx], keyword_rx)
1878
+ idx += 1 while idx + 1 < n && char_class(text[idx + 1], keyword_rx) == cls && cls != :space
1258
1879
  offset_to_cursor(buffer, idx)
1259
1880
  end
1260
1881
 
1261
- def next_word_start_offset(text, from_offset)
1882
+ def next_word_start_offset(text, from_offset, keyword_rx = nil)
1262
1883
  i = [from_offset, 0].max
1263
1884
  n = text.length
1264
1885
  return nil if i >= n
1265
1886
 
1266
- cls = char_class(text[i])
1887
+ cls = char_class(text[i], keyword_rx)
1267
1888
  if cls == :word
1268
- i += 1 while i < n && char_class(text[i]) == :word
1889
+ i += 1 while i < n && char_class(text[i], keyword_rx) == :word
1269
1890
  elsif cls == :space
1270
- i += 1 while i < n && char_class(text[i]) == :space
1891
+ i += 1 while i < n && char_class(text[i], keyword_rx) == :space
1271
1892
  else
1272
1893
  i += 1
1273
1894
  end
1274
- i += 1 while i < n && char_class(text[i]) == :space
1895
+ i += 1 while i < n && char_class(text[i], keyword_rx) == :space
1275
1896
  return n if i > n
1276
1897
 
1277
1898
  i <= n ? i : nil
1278
1899
  end
1279
1900
 
1280
- def char_class(ch)
1901
+ def char_class(ch, keyword_rx = nil)
1281
1902
  return :space if ch == "\n"
1282
1903
  return :space if ch =~ /\s/
1283
- return :word if ch =~ /[[:alnum:]_]/
1904
+ return :word if keyword_char?(ch, keyword_rx)
1284
1905
  :punct
1285
1906
  end
1286
1907
 
@@ -1306,11 +1927,12 @@ module RuVim
1306
1927
  x = nxt
1307
1928
  end
1308
1929
 
1309
- cls = line[x] =~ /[[:alnum:]_]/ ? :word : :punct
1930
+ keyword_rx = keyword_char_regex(nil, buffer, window)
1931
+ cls = keyword_char?(line[x], keyword_rx) ? :word : :punct
1310
1932
  start_col = x
1311
- start_col -= 1 while start_col.positive? && same_word_class?(line[start_col - 1], cls)
1933
+ start_col -= 1 while start_col.positive? && same_word_class?(line[start_col - 1], cls, keyword_rx)
1312
1934
  end_col = x + 1
1313
- end_col += 1 while end_col < line.length && same_word_class?(line[end_col], cls)
1935
+ end_col += 1 while end_col < line.length && same_word_class?(line[end_col], cls, keyword_rx)
1314
1936
 
1315
1937
  if around
1316
1938
  while end_col < line.length && line[end_col] =~ /\s/
@@ -1493,15 +2115,170 @@ module RuVim
1493
2115
  nil
1494
2116
  end
1495
2117
 
1496
- def same_word_class?(ch, cls)
2118
+ def same_word_class?(ch, cls, keyword_rx = nil)
1497
2119
  return false if ch.nil?
1498
2120
  case cls
1499
- when :word then ch =~ /[[:alnum:]_]/
1500
- when :punct then !(ch =~ /[[:alnum:]_\s]/)
2121
+ when :word then keyword_char?(ch, keyword_rx)
2122
+ when :punct then !(keyword_char?(ch, keyword_rx) || ch =~ /\s/)
1501
2123
  else false
1502
2124
  end
1503
2125
  end
1504
2126
 
2127
+ def keyword_char?(ch, keyword_rx = nil)
2128
+ return false if ch.nil?
2129
+
2130
+ (keyword_rx || /[[:alnum:]_]/).match?(ch)
2131
+ end
2132
+
2133
+ def keyword_char_regex(editor, buffer, window)
2134
+ win = window || editor&.current_window
2135
+ buf = buffer || editor&.current_buffer
2136
+ raw =
2137
+ if editor
2138
+ editor.effective_option("iskeyword", window: win, buffer: buf).to_s
2139
+ else
2140
+ buf&.options&.fetch("iskeyword", nil).to_s
2141
+ end
2142
+ RuVim::KeywordChars.regex(raw)
2143
+ end
2144
+
2145
+ def move_cursor_horizontally(ctx, direction:, count:)
2146
+ count = [count.to_i, 1].max
2147
+ allow_wrap = whichwrap_allows?(ctx, direction)
2148
+ virtualedit_mode = virtualedit_mode(ctx)
2149
+ count.times do
2150
+ line = ctx.buffer.line_at(ctx.window.cursor_y)
2151
+ if direction == :left
2152
+ if ctx.window.cursor_x > line.length && virtualedit_mode
2153
+ ctx.window.cursor_x -= 1
2154
+ elsif ctx.window.cursor_x.positive?
2155
+ ctx.window.cursor_x = RuVim::TextMetrics.previous_grapheme_char_index(line, ctx.window.cursor_x)
2156
+ elsif allow_wrap && ctx.window.cursor_y.positive?
2157
+ ctx.window.cursor_y -= 1
2158
+ ctx.window.cursor_x = ctx.buffer.line_length(ctx.window.cursor_y)
2159
+ end
2160
+ else
2161
+ max_right =
2162
+ case virtualedit_mode
2163
+ when :all then line.length + count + 1024 # practical cap; clamp below uses current cursor
2164
+ when :onemore then line.length + 1
2165
+ else line.length
2166
+ end
2167
+ if ctx.window.cursor_x < max_right
2168
+ ctx.window.cursor_x =
2169
+ if virtualedit_mode == :onemore && ctx.window.cursor_x == line.length
2170
+ line.length + 1
2171
+ elsif virtualedit_mode == :all && ctx.window.cursor_x >= line.length
2172
+ ctx.window.cursor_x + 1
2173
+ else
2174
+ RuVim::TextMetrics.next_grapheme_char_index(line, ctx.window.cursor_x)
2175
+ end
2176
+ ctx.window.cursor_x = [ctx.window.cursor_x, max_right].min
2177
+ elsif allow_wrap && ctx.window.cursor_y < ctx.buffer.line_count - 1
2178
+ ctx.window.cursor_y += 1
2179
+ ctx.window.cursor_x = 0
2180
+ end
2181
+ end
2182
+ end
2183
+ extra =
2184
+ case virtualedit_mode
2185
+ when :all
2186
+ [ctx.window.cursor_x - ctx.buffer.line_length(ctx.window.cursor_y), 0].max
2187
+ when :onemore
2188
+ 1
2189
+ else
2190
+ 0
2191
+ end
2192
+ ctx.window.clamp_to_buffer(ctx.buffer, max_extra_col: extra)
2193
+ end
2194
+
2195
+ def whichwrap_allows?(ctx, direction)
2196
+ toks = ctx.editor.effective_option("whichwrap", window: ctx.window, buffer: ctx.buffer).to_s
2197
+ .split(",").map { |s| s.strip.downcase }.reject(&:empty?)
2198
+ return false if toks.empty?
2199
+
2200
+ if direction == :left
2201
+ toks.include?("h") || toks.include?("<") || toks.include?("left")
2202
+ else
2203
+ toks.include?("l") || toks.include?(">") || toks.include?("right")
2204
+ end
2205
+ end
2206
+
2207
+ def virtualedit_mode(ctx)
2208
+ spec = ctx.editor.effective_option("virtualedit", window: ctx.window, buffer: ctx.buffer).to_s
2209
+ toks = spec.split(",").map { |s| s.strip.downcase }
2210
+ return :all if toks.include?("all")
2211
+ return :onemore if toks.include?("onemore")
2212
+
2213
+ nil
2214
+ end
2215
+
2216
+ def current_view_height(ctx)
2217
+ hint = ctx.editor.respond_to?(:current_window_view_height_hint) ? ctx.editor.current_window_view_height_hint : nil
2218
+ [hint.to_i, 1].max
2219
+ rescue StandardError
2220
+ 1
2221
+ end
2222
+
2223
+ def current_page_step_lines(ctx)
2224
+ [current_view_height(ctx) - 1, 1].max
2225
+ end
2226
+
2227
+ def current_half_page_step_lines(ctx)
2228
+ [current_view_height(ctx) / 2, 1].max
2229
+ end
2230
+
2231
+ def scroll_window_vertically(ctx, direction:, lines:, view_height:, count:)
2232
+ step = [[lines.to_i, 1].max * [count.to_i, 1].max, 1].max
2233
+ height = [view_height.to_i, 1].max
2234
+ max_row_offset = [ctx.buffer.line_count - height, 0].max
2235
+
2236
+ before = ctx.window.row_offset.to_i
2237
+ after =
2238
+ if direction == :up
2239
+ [before - step, 0].max
2240
+ else
2241
+ [before + step, max_row_offset].min
2242
+ end
2243
+ return if after == before
2244
+
2245
+ ctx.window.row_offset = after
2246
+
2247
+ # Vim-like behavior: scroll viewport first, then keep cursor inside it.
2248
+ top = after
2249
+ bottom = after + height - 1
2250
+ if ctx.window.cursor_y < top
2251
+ ctx.window.cursor_y = top
2252
+ elsif ctx.window.cursor_y > bottom
2253
+ ctx.window.cursor_y = bottom
2254
+ end
2255
+ ctx.window.clamp_to_buffer(ctx.buffer)
2256
+ end
2257
+
2258
+ def place_cursor_line_in_window(ctx, where:, count:)
2259
+ if count
2260
+ target_row = [[normalized_count(count) - 1, 0].max, ctx.buffer.line_count - 1].min
2261
+ ctx.window.cursor_y = target_row
2262
+ ctx.window.clamp_to_buffer(ctx.buffer)
2263
+ end
2264
+
2265
+ height = current_view_height(ctx)
2266
+ max_row_offset = [ctx.buffer.line_count - height, 0].max
2267
+ desired =
2268
+ case where
2269
+ when :top
2270
+ ctx.window.cursor_y
2271
+ when :center
2272
+ ctx.window.cursor_y - (height / 2)
2273
+ when :bottom
2274
+ ctx.window.cursor_y - height + 1
2275
+ else
2276
+ ctx.window.row_offset
2277
+ end
2278
+ ctx.window.row_offset = [[desired, 0].max, max_row_offset].min
2279
+ ctx.window.clamp_to_buffer(ctx.buffer)
2280
+ end
2281
+
1505
2282
  def cursor_to_offset(buffer, row, col)
1506
2283
  offset = 0
1507
2284
  row.times { |r| offset += buffer.line_length(r) + 1 }
@@ -1537,6 +2314,7 @@ module RuVim
1537
2314
  lines = text.sub(/\n\z/, "").split("\n", -1)
1538
2315
  return if lines.empty?
1539
2316
 
2317
+ count = normalized_count(count)
1540
2318
  insert_at = before ? ctx.window.cursor_y : (ctx.window.cursor_y + 1)
1541
2319
  ctx.buffer.begin_change_group
1542
2320
  count.times { ctx.buffer.insert_lines_at(insert_at, lines) }
@@ -1612,13 +2390,143 @@ module RuVim
1612
2390
  nil
1613
2391
  end
1614
2392
 
2393
+ def normalized_count(count, default: 1)
2394
+ n = count.nil? ? default : count.to_i
2395
+ n = default if n <= 0
2396
+ n
2397
+ end
2398
+
2399
+ def ensure_modifiable_for_insert!(ctx)
2400
+ raise RuVim::CommandError, "Buffer is not modifiable" unless ctx.buffer.modifiable?
2401
+ end
2402
+
2403
+ def maybe_autowrite_before_switch(ctx)
2404
+ return false unless ctx.editor.effective_option("autowrite", window: ctx.window, buffer: ctx.buffer)
2405
+ return false unless ctx.buffer.file_buffer?
2406
+ return false unless ctx.buffer.path && !ctx.buffer.path.empty?
2407
+
2408
+ ctx.buffer.write_to(ctx.buffer.path)
2409
+ true
2410
+ rescue StandardError
2411
+ false
2412
+ end
2413
+
2414
+ def file_token_under_cursor(buffer, window)
2415
+ line = buffer.line_at(window.cursor_y)
2416
+ return nil if line.empty?
2417
+
2418
+ x = [[window.cursor_x, 0].max, [line.length - 1, 0].max].min
2419
+ file_char = /[[:alnum:]_\.\/~:-]/
2420
+ if line[x] !~ file_char
2421
+ left = x - 1
2422
+ right = x + 1
2423
+ if left >= 0 && line[left] =~ file_char
2424
+ x = left
2425
+ elsif right < line.length && line[right] =~ file_char
2426
+ x = right
2427
+ else
2428
+ return nil
2429
+ end
2430
+ end
2431
+
2432
+ s = x
2433
+ e = x + 1
2434
+ s -= 1 while s.positive? && line[s - 1] =~ file_char
2435
+ e += 1 while e < line.length && line[e] =~ file_char
2436
+ line[s...e]
2437
+ end
2438
+
2439
+ def parse_gf_target(token)
2440
+ raw = token.to_s
2441
+ if (m = /\A(.+):(\d+)\z/.match(raw))
2442
+ return { path: m[1], line: m[2].to_i } unless m[1].end_with?(":")
2443
+ end
2444
+ { path: raw, line: nil }
2445
+ end
2446
+
2447
+ def move_cursor_to_gf_line(ctx, line_no)
2448
+ line = line_no.to_i
2449
+ return if line <= 0
2450
+
2451
+ w = ctx.editor.current_window
2452
+ b = ctx.editor.current_buffer
2453
+ w.cursor_y = [line - 1, b.line_count - 1].min
2454
+ w.cursor_x = 0
2455
+ w.clamp_to_buffer(b)
2456
+ end
2457
+
2458
+ def resolve_gf_path(ctx, token)
2459
+ candidates = gf_candidate_paths(ctx, token.to_s)
2460
+ candidates.find { |p| File.file?(p) || File.directory?(p) }
2461
+ end
2462
+
2463
+ def gf_candidate_paths(ctx, token)
2464
+ suffixes = gf_suffixes(ctx)
2465
+ names = [token]
2466
+ if File.extname(token).empty?
2467
+ suffixes.each { |suf| names << "#{token}#{suf}" }
2468
+ end
2469
+ names.uniq!
2470
+
2471
+ if token.start_with?("/", "~/")
2472
+ return names.map { |n| File.expand_path(n) }.uniq
2473
+ end
2474
+
2475
+ base_dirs = gf_search_dirs(ctx)
2476
+ base_dirs.product(names).map { |dir, name| File.expand_path(name, dir) }.uniq
2477
+ end
2478
+
2479
+ def gf_search_dirs(ctx)
2480
+ current_dir = if ctx.buffer.path && !ctx.buffer.path.empty?
2481
+ File.dirname(File.expand_path(ctx.buffer.path))
2482
+ else
2483
+ Dir.pwd
2484
+ end
2485
+ raw = ctx.editor.effective_option("path", window: ctx.window, buffer: ctx.buffer).to_s
2486
+ dirs = raw.split(",").map(&:strip).reject(&:empty?)
2487
+ dirs = ["."] if dirs.empty?
2488
+ dirs.flat_map do |dir|
2489
+ expand_gf_path_entry(dir, current_dir)
2490
+ end.uniq
2491
+ rescue StandardError
2492
+ [Dir.pwd]
2493
+ end
2494
+
2495
+ def gf_suffixes(ctx)
2496
+ raw = ctx.editor.effective_option("suffixesadd", window: ctx.window, buffer: ctx.buffer).to_s
2497
+ raw.split(",").map(&:strip).reject(&:empty?).map do |s|
2498
+ s.start_with?(".") ? s : ".#{s}"
2499
+ end
2500
+ end
2501
+
2502
+ def expand_gf_path_entry(entry, current_dir)
2503
+ dir = entry.to_s
2504
+ return [current_dir] if dir.empty? || dir == "."
2505
+
2506
+ expanded = File.expand_path(dir, current_dir)
2507
+ if expanded.end_with?("/**")
2508
+ base = expanded[0...-3]
2509
+ [base, *Dir.glob(File.join(base, "**", "*"))].select { |p| File.directory?(p) }
2510
+ elsif expanded.end_with?("**")
2511
+ base = expanded.sub(/\*\*\z/, "")
2512
+ base = base.sub(%r{/+\z}, "")
2513
+ [base, *Dir.glob(File.join(base, "**", "*"))].select { |p| File.directory?(p) }
2514
+ elsif expanded.match?(/[*?\[]/)
2515
+ Dir.glob(expanded).select { |p| File.directory?(p) }
2516
+ else
2517
+ [expanded]
2518
+ end
2519
+ rescue StandardError
2520
+ [expanded || File.expand_path(dir, current_dir)]
2521
+ end
2522
+
1615
2523
  def ex_set_common(ctx, argv, scope:)
1616
2524
  editor = ctx.editor
1617
2525
  if argv.empty?
1618
2526
  items = editor.option_snapshot(window: ctx.window, buffer: ctx.buffer).map do |opt|
1619
2527
  format_option_value(opt[:name], opt[:effective])
1620
2528
  end
1621
- ctx.editor.echo(items.join(" "))
2529
+ ctx.editor.echo_multiline(items)
1622
2530
  return
1623
2531
  end
1624
2532
 
@@ -1766,7 +2674,235 @@ module RuVim
1766
2674
  ]
1767
2675
  end
1768
2676
 
2677
+ def parse_bindings_args(argv)
2678
+ mode_filter = nil
2679
+ sort = "key"
2680
+
2681
+ Array(argv).each do |raw|
2682
+ token = raw.to_s.strip
2683
+ next if token.empty?
2684
+
2685
+ if token.include?("=")
2686
+ key, value = token.split("=", 2).map(&:strip)
2687
+ case key.downcase
2688
+ when "sort"
2689
+ sort = parse_bindings_sort(value)
2690
+ else
2691
+ raise RuVim::CommandError, "Unknown option for :bindings: #{key}"
2692
+ end
2693
+ next
2694
+ end
2695
+
2696
+ raise RuVim::CommandError, "Too many positional args for :bindings" if mode_filter
2697
+
2698
+ mode_filter = parse_bindings_mode_filter(token)
2699
+ end
2700
+
2701
+ [mode_filter, sort]
2702
+ end
2703
+
2704
+ def parse_bindings_sort(raw)
2705
+ token = raw.to_s.strip.downcase
2706
+ case token
2707
+ when "", "key", "keys" then "key"
2708
+ when "command", "cmd" then "command"
2709
+ else
2710
+ raise RuVim::CommandError, "Unknown sort for :bindings: #{raw}"
2711
+ end
2712
+ end
2713
+
2714
+ def parse_bindings_mode_filter(raw)
2715
+ return nil if raw.nil? || raw.to_s.strip.empty?
2716
+
2717
+ token = raw.to_s.strip.downcase
2718
+ case token
2719
+ when "n", "normal" then :normal
2720
+ when "i", "insert" then :insert
2721
+ when "v", "visual", "visual_char" then :visual_char
2722
+ when "vl", "visual_line" then :visual_line
2723
+ when "vb", "visual_block", "x" then :visual_block
2724
+ when "o", "operator", "operator_pending" then :operator_pending
2725
+ when "c", "cmdline", "command", "command_line" then :command_line
2726
+ else
2727
+ raise RuVim::CommandError, "Unknown mode for :bindings: #{raw}"
2728
+ end
2729
+ end
2730
+
2731
+ def bindings_buffer_lines(editor, entries, mode_filter:, sort:)
2732
+ buffer = editor.current_buffer
2733
+ filetype = buffer.options["filetype"].to_s
2734
+ filetype = nil if filetype.empty?
2735
+
2736
+ lines = [
2737
+ "Bindings",
2738
+ "",
2739
+ "Buffer: #{buffer.display_name}",
2740
+ "Filetype: #{filetype || '-'}",
2741
+ "Mode filter: #{mode_filter || 'all'}",
2742
+ "Sort: #{sort}",
2743
+ ""
2744
+ ]
2745
+
2746
+ any = false
2747
+ %i[buffer filetype app].each do |layer|
2748
+ layer_entries = entries.select { |e| e.layer == layer }
2749
+ next if layer_entries.empty?
2750
+
2751
+ any = true
2752
+ lines << "Layer: #{layer}"
2753
+ append_binding_entries_grouped!(lines, layer_entries, layer:, sort:)
2754
+ lines << ""
2755
+ end
2756
+
2757
+ lines << "(no bindings)" unless any
2758
+ lines
2759
+ end
2760
+
2761
+ def append_binding_entries_grouped!(lines, entries, layer:, sort:)
2762
+ groups = entries.group_by do |e|
2763
+ if layer == :app && e.scope == :global
2764
+ [:global, nil]
2765
+ elsif e.mode
2766
+ [:mode, e.mode]
2767
+ else
2768
+ [:plain, nil]
2769
+ end
2770
+ end
2771
+
2772
+ groups.keys.sort_by { |kind, mode| binding_group_sort_key(kind, mode) }.each do |kind, mode|
2773
+ group_entries = groups[[kind, mode]]
2774
+ next if group_entries.nil? || group_entries.empty?
2775
+
2776
+ if kind == :global
2777
+ lines << " [global]"
2778
+ elsif mode
2779
+ lines << " [#{mode}]"
2780
+ end
2781
+
2782
+ group_entries = sort_binding_entries(group_entries, sort:)
2783
+ parts = group_entries.map { |entry| binding_entry_display_parts(entry) }
2784
+ rhs_width = parts.map { |_, rhs, _| rhs.length }.max || 0
2785
+ parts.each do |lhs, rhs, desc|
2786
+ lines << format_binding_entry_line(lhs, rhs, desc, rhs_width:)
2787
+ end
2788
+ end
2789
+ end
2790
+
2791
+ def binding_group_sort_key(kind, mode)
2792
+ rank =
2793
+ case kind
2794
+ when :plain then 0
2795
+ when :mode then 1
2796
+ when :global then 2
2797
+ else 9
2798
+ end
2799
+ [rank, binding_mode_order_index(mode), mode.to_s]
2800
+ end
2801
+
2802
+ def binding_mode_order_index(mode)
2803
+ return -1 if mode.nil?
2804
+
2805
+ order = {
2806
+ normal: 0,
2807
+ insert: 1,
2808
+ visual_char: 2,
2809
+ visual_line: 3,
2810
+ visual_block: 4,
2811
+ operator_pending: 5,
2812
+ command_line: 6
2813
+ }
2814
+ order.fetch(mode.to_sym, 99)
2815
+ end
2816
+
2817
+ def sort_binding_entries(entries, sort:)
2818
+ case sort.to_s
2819
+ when "command"
2820
+ entries.sort_by do |e|
2821
+ [e.id.to_s, format_binding_tokens(e.tokens), e.bang ? 1 : 0, e.argv.inspect, e.kwargs.inspect]
2822
+ end
2823
+ else
2824
+ entries
2825
+ end
2826
+ end
2827
+
2828
+ def binding_entry_display_parts(entry)
2829
+ lhs = format_binding_tokens(entry.tokens)
2830
+ rhs = entry.id.to_s
2831
+ rhs += "!" if entry.bang
2832
+ rhs += " argv=#{entry.argv.inspect}" unless entry.argv.nil? || entry.argv.empty?
2833
+ rhs += " kwargs=#{entry.kwargs.inspect}" unless entry.kwargs.nil? || entry.kwargs.empty?
2834
+ desc = binding_command_desc(entry.id)
2835
+ [lhs, rhs, desc.to_s]
2836
+ end
2837
+
2838
+ def format_binding_entry_line(lhs, rhs, desc, rhs_width:)
2839
+ line = " #{lhs.ljust(18)} #{rhs.ljust(rhs_width)}"
2840
+ line += " #{desc}" unless desc.to_s.empty?
2841
+ line
2842
+ end
2843
+
2844
+ def format_binding_tokens(tokens)
2845
+ Array(tokens).map { |t| format_binding_token(t) }.join
2846
+ end
2847
+
2848
+ def format_binding_token(token)
2849
+ case token.to_s
2850
+ when "\e" then "<Esc>"
2851
+ when "\t" then "<Tab>"
2852
+ when "\r" then "<CR>"
2853
+ else token.to_s
2854
+ end
2855
+ end
2856
+
2857
+ def binding_command_desc(command_id)
2858
+ RuVim::CommandRegistry.instance.fetch(command_id).desc.to_s
2859
+ rescue StandardError
2860
+ ""
2861
+ end
2862
+
2863
+ def ex_command_binding_labels(editor, ex_spec)
2864
+ keymaps = editor.keymap_manager
2865
+ return [] unless keymaps
2866
+
2867
+ command_ids = command_ids_for_ex_callable(ex_spec.call)
2868
+ return [] if command_ids.empty?
2869
+
2870
+ entries = keymaps.binding_entries_for_context(editor).select do |entry|
2871
+ entry.layer == :app && command_ids.include?(entry.id.to_s)
2872
+ end
2873
+ entries.sort_by do |entry|
2874
+ [binding_mode_order_index(entry.mode), entry.scope == :global ? 1 : 0, format_binding_tokens(entry.tokens)]
2875
+ end.map do |entry|
2876
+ format_ex_command_binding_label(entry)
2877
+ end.uniq
2878
+ end
2879
+
2880
+ def command_ids_for_ex_callable(callable)
2881
+ RuVim::CommandRegistry.instance.all.filter_map do |spec|
2882
+ spec.id if same_command_callable?(spec.call, callable)
2883
+ end
2884
+ end
2885
+
2886
+ def same_command_callable?(a, b)
2887
+ if (a.is_a?(Symbol) || a.is_a?(String)) && (b.is_a?(Symbol) || b.is_a?(String))
2888
+ return a.to_sym == b.to_sym
2889
+ end
2890
+ a.equal?(b)
2891
+ end
2892
+
2893
+ def format_ex_command_binding_label(entry)
2894
+ lhs = format_binding_tokens(entry.tokens)
2895
+ if entry.scope == :global
2896
+ "global:#{lhs}"
2897
+ elsif entry.mode && entry.mode.to_sym != :normal
2898
+ "#{entry.mode}:#{lhs}"
2899
+ else
2900
+ lhs
2901
+ end
2902
+ end
2903
+
1769
2904
  def paste_charwise(ctx, text, before:, count:)
2905
+ count = normalized_count(count)
1770
2906
  y = ctx.window.cursor_y
1771
2907
  x = ctx.window.cursor_x
1772
2908
  insert_col = before ? x : [x + 1, ctx.buffer.line_length(y)].min
@@ -1797,7 +2933,7 @@ module RuVim
1797
2933
  end
1798
2934
 
1799
2935
  def move_to_search(ctx, pattern:, direction:, count:)
1800
- count = 1 if count.to_i <= 0
2936
+ count = normalized_count(count)
1801
2937
  regex = compile_search_regex(pattern, editor: ctx.editor, window: ctx.window, buffer: ctx.buffer)
1802
2938
  count.times do
1803
2939
  match = find_next_match(ctx.buffer, ctx.window, regex, direction: direction)
@@ -1885,5 +3021,141 @@ module RuVim
1885
3021
  rescue StandardError
1886
3022
  0
1887
3023
  end
3024
+
3025
+ def parse_substitute_flags(flags_str, default_global: false)
3026
+ flags = { global: default_global, ignore_case: false, match_case: false, count_only: false, no_error: false, confirm: false }
3027
+ return flags if flags_str.nil? || flags_str.empty?
3028
+
3029
+ flags_str.each_char do |ch|
3030
+ case ch
3031
+ when "g" then flags[:global] = true
3032
+ when "i" then flags[:ignore_case] = true
3033
+ when "I" then flags[:match_case] = true
3034
+ when "n" then flags[:count_only] = true
3035
+ when "e" then flags[:no_error] = true
3036
+ when "c" then flags[:confirm] = true
3037
+ end
3038
+ end
3039
+ flags
3040
+ end
3041
+
3042
+ def build_substitute_regex(pattern, flags, ctx)
3043
+ if flags[:match_case]
3044
+ # I flag: force case-sensitive
3045
+ Regexp.new(pattern.to_s)
3046
+ elsif flags[:ignore_case]
3047
+ # i flag: force case-insensitive
3048
+ Regexp.new(pattern.to_s, Regexp::IGNORECASE)
3049
+ else
3050
+ compile_search_regex(pattern, editor: ctx.editor, window: ctx.window, buffer: ctx.buffer)
3051
+ end
3052
+ rescue RegexpError => e
3053
+ raise RuVim::CommandError, "Invalid regex: #{e.message}"
3054
+ end
3055
+
3056
+ def substitute_range(ctx, regex, replacement, r_start, r_end, flags)
3057
+ changed = 0
3058
+ new_lines = ctx.buffer.lines.each_with_index.map do |line, idx|
3059
+ if idx >= r_start && idx <= r_end
3060
+ if flags[:global]
3061
+ line.scan(regex) { changed += 1 }
3062
+ line.gsub(regex, replacement)
3063
+ else
3064
+ if line.match?(regex)
3065
+ changed += 1
3066
+ line.sub(regex, replacement)
3067
+ else
3068
+ line
3069
+ end
3070
+ end
3071
+ else
3072
+ line
3073
+ end
3074
+ end
3075
+
3076
+ if changed.positive?
3077
+ ctx.buffer.begin_change_group
3078
+ ctx.buffer.replace_all_lines!(new_lines)
3079
+ ctx.buffer.end_change_group
3080
+ end
3081
+ changed
3082
+ end
3083
+
3084
+ def count_matches_in_range(buffer, regex, r_start, r_end, global)
3085
+ total = 0
3086
+ (r_start..r_end).each do |idx|
3087
+ line = buffer.line_at(idx)
3088
+ if global
3089
+ line.scan(regex) { total += 1 }
3090
+ else
3091
+ total += 1 if line.match?(regex)
3092
+ end
3093
+ end
3094
+ total
3095
+ end
3096
+
3097
+ public
3098
+
3099
+ def arglist_show(ctx, **)
3100
+ arglist = ctx.editor.arglist
3101
+ if arglist.empty?
3102
+ ctx.editor.echo("No arguments")
3103
+ return
3104
+ end
3105
+
3106
+ current_index = ctx.editor.arglist_index
3107
+ items = arglist.map.with_index do |path, i|
3108
+ if i == current_index
3109
+ "[#{path}]"
3110
+ else
3111
+ " #{path}"
3112
+ end
3113
+ end
3114
+ ctx.editor.echo_multiline(items)
3115
+ end
3116
+
3117
+ def arglist_next(ctx, count:, **)
3118
+ count = normalized_count(count)
3119
+ path = ctx.editor.arglist_next(count)
3120
+ switch_to_file(ctx, path)
3121
+ ctx.editor.echo("Argument #{ctx.editor.arglist_index + 1} of #{ctx.editor.arglist.length}: #{path}")
3122
+ end
3123
+
3124
+ def arglist_prev(ctx, count:, **)
3125
+ count = normalized_count(count)
3126
+ path = ctx.editor.arglist_prev(count)
3127
+ switch_to_file(ctx, path)
3128
+ ctx.editor.echo("Argument #{ctx.editor.arglist_index + 1} of #{ctx.editor.arglist.length}: #{path}")
3129
+ end
3130
+
3131
+ def arglist_first(ctx, **)
3132
+ path = ctx.editor.arglist_first
3133
+ return ctx.editor.error("No arguments") unless path
3134
+ switch_to_file(ctx, path)
3135
+ ctx.editor.echo("Argument 1 of #{ctx.editor.arglist.length}: #{path}")
3136
+ end
3137
+
3138
+ def arglist_last(ctx, **)
3139
+ path = ctx.editor.arglist_last
3140
+ return ctx.editor.error("No arguments") unless path
3141
+ switch_to_file(ctx, path)
3142
+ ctx.editor.echo("Argument #{ctx.editor.arglist.length} of #{ctx.editor.arglist.length}: #{path}")
3143
+ end
3144
+
3145
+ private
3146
+
3147
+ def switch_to_file(ctx, path)
3148
+ existing_buffer = ctx.editor.buffers.values.find { |buf| buf.path == path }
3149
+ if existing_buffer
3150
+ ctx.editor.set_alternate_buffer_id(ctx.editor.current_buffer.id)
3151
+ ctx.editor.activate_buffer(existing_buffer.id)
3152
+ existing_buffer.id
3153
+ else
3154
+ ctx.editor.set_alternate_buffer_id(ctx.editor.current_buffer.id)
3155
+ buffer = ctx.editor.add_buffer_from_file(path)
3156
+ ctx.editor.current_window.buffer_id = buffer.id
3157
+ buffer.id
3158
+ end
3159
+ end
1888
3160
  end
1889
3161
  end