ruvim 0.2.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 (63) 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 +23 -0
  6. data/docs/command.md +85 -0
  7. data/docs/config.md +2 -2
  8. data/docs/done.md +21 -0
  9. data/docs/spec.md +157 -12
  10. data/docs/todo.md +1 -5
  11. data/docs/vim_diff.md +94 -172
  12. data/lib/ruvim/app.rb +882 -69
  13. data/lib/ruvim/buffer.rb +35 -1
  14. data/lib/ruvim/cli.rb +12 -3
  15. data/lib/ruvim/clipboard.rb +2 -0
  16. data/lib/ruvim/command_invocation.rb +3 -1
  17. data/lib/ruvim/command_line.rb +2 -0
  18. data/lib/ruvim/command_registry.rb +2 -0
  19. data/lib/ruvim/config_dsl.rb +2 -0
  20. data/lib/ruvim/config_loader.rb +2 -0
  21. data/lib/ruvim/context.rb +2 -0
  22. data/lib/ruvim/dispatcher.rb +143 -13
  23. data/lib/ruvim/display_width.rb +3 -0
  24. data/lib/ruvim/editor.rb +455 -71
  25. data/lib/ruvim/ex_command_registry.rb +2 -0
  26. data/lib/ruvim/global_commands.rb +890 -63
  27. data/lib/ruvim/highlighter.rb +16 -21
  28. data/lib/ruvim/input.rb +39 -28
  29. data/lib/ruvim/keymap_manager.rb +83 -0
  30. data/lib/ruvim/keyword_chars.rb +2 -0
  31. data/lib/ruvim/lang/base.rb +25 -0
  32. data/lib/ruvim/lang/csv.rb +18 -0
  33. data/lib/ruvim/lang/json.rb +18 -0
  34. data/lib/ruvim/lang/markdown.rb +170 -0
  35. data/lib/ruvim/lang/ruby.rb +236 -0
  36. data/lib/ruvim/lang/scheme.rb +44 -0
  37. data/lib/ruvim/lang/tsv.rb +19 -0
  38. data/lib/ruvim/rich_view/markdown_renderer.rb +248 -0
  39. data/lib/ruvim/rich_view/table_renderer.rb +176 -0
  40. data/lib/ruvim/rich_view.rb +93 -0
  41. data/lib/ruvim/screen.rb +503 -106
  42. data/lib/ruvim/terminal.rb +18 -1
  43. data/lib/ruvim/text_metrics.rb +2 -0
  44. data/lib/ruvim/version.rb +1 -1
  45. data/lib/ruvim/window.rb +2 -0
  46. data/lib/ruvim.rb +14 -0
  47. data/test/app_completion_test.rb +73 -0
  48. data/test/app_dot_repeat_test.rb +13 -0
  49. data/test/app_motion_test.rb +13 -0
  50. data/test/app_scenario_test.rb +729 -1
  51. data/test/app_startup_test.rb +187 -0
  52. data/test/arglist_test.rb +113 -0
  53. data/test/buffer_test.rb +49 -30
  54. data/test/dispatcher_test.rb +322 -0
  55. data/test/editor_register_test.rb +23 -0
  56. data/test/highlighter_test.rb +121 -0
  57. data/test/indent_test.rb +201 -0
  58. data/test/input_screen_integration_test.rb +40 -2
  59. data/test/markdown_renderer_test.rb +279 -0
  60. data/test/on_save_hook_test.rb +150 -0
  61. data/test/rich_view_test.rb +478 -0
  62. data/test/screen_test.rb +304 -0
  63. metadata +33 -2
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "tempfile"
2
4
  require "open3"
3
5
 
@@ -5,7 +7,7 @@ module RuVim
5
7
  class GlobalCommands
6
8
  include Singleton
7
9
 
8
- def call(spec_call, ctx, argv: [], kwargs: {}, bang: false, count: 1)
10
+ def call(spec_call, ctx, argv: [], kwargs: {}, bang: false, count: nil)
9
11
  case spec_call
10
12
  when Symbol, String
11
13
  public_send(spec_call.to_sym, ctx, argv: argv, kwargs: kwargs, bang: bang, count: count)
@@ -23,11 +25,11 @@ module RuVim
23
25
  end
24
26
 
25
27
  def cursor_up(ctx, count:, **)
26
- ctx.window.move_up(ctx.buffer, count)
28
+ ctx.window.move_up(ctx.buffer, normalized_count(count))
27
29
  end
28
30
 
29
31
  def cursor_down(ctx, count:, **)
30
- ctx.window.move_down(ctx.buffer, count)
32
+ ctx.window.move_down(ctx.buffer, normalized_count(count))
31
33
  end
32
34
 
33
35
  def cursor_page_up(ctx, kwargs:, count:, **)
@@ -72,6 +74,18 @@ module RuVim
72
74
  call(:window_scroll_down, ctx, count:, bang:, kwargs: { lines: 1, view_height: current_view_height(ctx) + 1 })
73
75
  end
74
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
+
75
89
  def cursor_line_start(ctx, **)
76
90
  ctx.window.cursor_x = 0
77
91
  ctx.window.clamp_to_buffer(ctx.buffer)
@@ -91,7 +105,7 @@ module RuVim
91
105
 
92
106
  def cursor_buffer_start(ctx, count:, **)
93
107
  record_jump(ctx)
94
- target_row = [count.to_i - 1, 0].max
108
+ target_row = [normalized_count(count).to_i - 1, 0].max
95
109
  target_row = [target_row, ctx.buffer.line_count - 1].min
96
110
  ctx.window.cursor_y = target_row
97
111
  cursor_first_nonblank(ctx)
@@ -99,11 +113,7 @@ module RuVim
99
113
 
100
114
  def cursor_buffer_end(ctx, count:, **)
101
115
  record_jump(ctx)
102
- if count && count > 1
103
- target_row = [count - 1, ctx.buffer.line_count - 1].min
104
- else
105
- target_row = ctx.buffer.line_count - 1
106
- end
116
+ target_row = count.nil? ? (ctx.buffer.line_count - 1) : [normalized_count(count) - 1, ctx.buffer.line_count - 1].min
107
117
  ctx.window.cursor_y = target_row
108
118
  cursor_first_nonblank(ctx)
109
119
  end
@@ -156,6 +166,7 @@ module RuVim
156
166
 
157
167
  def enter_insert_mode(ctx, **)
158
168
  materialize_intro_buffer_if_needed(ctx)
169
+ ensure_modifiable_for_insert!(ctx)
159
170
  ctx.buffer.begin_change_group
160
171
  ctx.editor.enter_insert_mode
161
172
  ctx.editor.echo("-- INSERT --")
@@ -180,6 +191,7 @@ module RuVim
180
191
 
181
192
  def open_line_below(ctx, **)
182
193
  materialize_intro_buffer_if_needed(ctx)
194
+ ensure_modifiable_for_insert!(ctx)
183
195
  y = ctx.window.cursor_y
184
196
  x = ctx.buffer.line_length(y)
185
197
  ctx.buffer.begin_change_group
@@ -193,6 +205,7 @@ module RuVim
193
205
 
194
206
  def open_line_above(ctx, **)
195
207
  materialize_intro_buffer_if_needed(ctx)
208
+ ensure_modifiable_for_insert!(ctx)
196
209
  y = ctx.window.cursor_y
197
210
  ctx.buffer.begin_change_group
198
211
  _new_y, new_x = ctx.buffer.insert_newline(y, 0)
@@ -250,6 +263,42 @@ module RuVim
250
263
  ctx.editor.focus_window_direction(:down)
251
264
  end
252
265
 
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)
281
+ end
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)
288
+ else
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
+
253
302
  def tab_new(ctx, argv:, **)
254
303
  path = argv[0]
255
304
  if ctx.buffer.modified? && !ctx.editor.effective_option("hidden", window: ctx.window, buffer: ctx.buffer)
@@ -258,24 +307,39 @@ module RuVim
258
307
  return
259
308
  end
260
309
  end
261
- tab = ctx.editor.tabnew(path: path)
262
- if path && !path.empty?
263
- b = ctx.editor.current_buffer
264
- ctx.editor.echo("tab #{ctx.editor.current_tabpage_number}/#{ctx.editor.tabpage_count}: #{b.path || '[No Name]'}")
265
- else
266
- ctx.editor.echo("tab #{ctx.editor.current_tabpage_number}/#{ctx.editor.tabpage_count}")
267
- end
268
- tab
310
+ ctx.editor.tabnew(path: path)
269
311
  end
270
312
 
271
313
  def tab_next(ctx, count:, **)
314
+ count = normalized_count(count)
272
315
  ctx.editor.tabnext(count)
273
- ctx.editor.echo("tab #{ctx.editor.current_tabpage_number}/#{ctx.editor.tabpage_count}")
274
316
  end
275
317
 
276
318
  def tab_prev(ctx, count:, **)
319
+ count = normalized_count(count)
277
320
  ctx.editor.tabprev(count)
278
- 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)
279
343
  end
280
344
 
281
345
  def enter_command_line_mode(ctx, **)
@@ -295,6 +359,7 @@ module RuVim
295
359
 
296
360
  def delete_char(ctx, count:, **)
297
361
  materialize_intro_buffer_if_needed(ctx)
362
+ count = normalized_count(count)
298
363
  ctx.buffer.begin_change_group
299
364
  deleted = +""
300
365
  count.times do
@@ -308,8 +373,80 @@ module RuVim
308
373
  ctx.window.clamp_to_buffer(ctx.buffer)
309
374
  end
310
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
+
311
447
  def delete_line(ctx, count:, **)
312
448
  materialize_intro_buffer_if_needed(ctx)
449
+ count = normalized_count(count)
313
450
  ctx.buffer.begin_change_group
314
451
  deleted_lines = []
315
452
  count.times { deleted_lines << ctx.buffer.delete_line(ctx.window.cursor_y) }
@@ -321,14 +458,15 @@ module RuVim
321
458
  def delete_motion(ctx, count:, kwargs:, **)
322
459
  materialize_intro_buffer_if_needed(ctx)
323
460
  motion = (kwargs[:motion] || kwargs["motion"]).to_s
461
+ ncount = normalized_count(count)
324
462
  handled =
325
463
  case motion
326
- when "h" then delete_chars_left(ctx, count)
327
- when "l" then delete_chars_right(ctx, count)
328
- when "j" then delete_lines_down(ctx, count)
329
- 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)
330
468
  when "$" then delete_to_end_of_line(ctx)
331
- when "w" then delete_word_forward(ctx, count)
469
+ when "w" then delete_word_forward(ctx, ncount)
332
470
  when "iw" then delete_text_object_word(ctx, around: false)
333
471
  when "aw" then delete_text_object_word(ctx, around: true)
334
472
  else
@@ -433,6 +571,7 @@ module RuVim
433
571
 
434
572
  def replace_char(ctx, argv:, count:, **)
435
573
  materialize_intro_buffer_if_needed(ctx)
574
+ count = normalized_count(count)
436
575
  ch = argv[0].to_s
437
576
  raise RuVim::CommandError, "replace requires a character" if ch.empty?
438
577
 
@@ -453,6 +592,7 @@ module RuVim
453
592
  end
454
593
 
455
594
  def yank_line(ctx, count:, **)
595
+ count = normalized_count(count)
456
596
  start = ctx.window.cursor_y
457
597
  text = ctx.buffer.line_block_text(start, count)
458
598
  store_yank_register(ctx, text:, type: :linewise)
@@ -557,6 +697,45 @@ module RuVim
557
697
  ctx.editor.enter_normal_mode
558
698
  end
559
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
+
560
739
  def visual_select_text_object(ctx, kwargs:, **)
561
740
  motion = (kwargs[:motion] || kwargs["motion"]).to_s
562
741
  span = text_object_span(ctx.buffer, ctx.window, motion)
@@ -582,6 +761,9 @@ module RuVim
582
761
  size = File.exist?(target) ? File.size(target) : 0
583
762
  suffix = bang ? " (force accepted)" : ""
584
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
585
767
  end
586
768
 
587
769
  def app_quit(ctx, bang:, **)
@@ -605,6 +787,25 @@ module RuVim
605
787
  ctx.editor.request_quit!
606
788
  end
607
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
+
608
809
  def file_write_quit(ctx, argv:, bang:, **)
609
810
  file_write(ctx, argv:, bang:)
610
811
  return unless ctx.editor.running?
@@ -639,9 +840,7 @@ module RuVim
639
840
  end
640
841
  end
641
842
 
642
- new_buffer = ctx.editor.add_buffer_from_file(path)
643
- ctx.editor.switch_to_buffer(new_buffer.id)
644
- ctx.editor.echo(File.exist?(path) ? "\"#{path}\" #{new_buffer.line_count}L" : "\"#{path}\" [New File]")
843
+ ctx.editor.open_path(path)
645
844
  end
646
845
 
647
846
  def file_goto_under_cursor(ctx, **)
@@ -651,9 +850,10 @@ module RuVim
651
850
  return
652
851
  end
653
852
 
654
- path = resolve_gf_path(ctx, token)
853
+ target = parse_gf_target(token)
854
+ path = resolve_gf_path(ctx, target[:path])
655
855
  unless path
656
- ctx.editor.echo_error("File not found: #{token}")
856
+ ctx.editor.echo_error("File not found: #{target[:path]}")
657
857
  return
658
858
  end
659
859
 
@@ -665,6 +865,7 @@ module RuVim
665
865
  end
666
866
 
667
867
  ctx.editor.open_path(path)
868
+ move_cursor_to_gf_line(ctx, target[:line]) if target[:line]
668
869
  end
669
870
 
670
871
  def buffer_list(ctx, **)
@@ -672,23 +873,28 @@ module RuVim
672
873
  alt_id = ctx.editor.alternate_buffer_id
673
874
  items = ctx.editor.buffer_ids.map do |id|
674
875
  b = ctx.editor.buffers.fetch(id)
675
- flags = ""
676
- flags << "%" if id == current_id
677
- flags << "#" if id == alt_id
678
- flags << "+" if b.modified?
679
- path = b.path || "[No Name]"
680
- "#{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]
681
885
  end
682
- ctx.editor.echo(items.join(" | "))
886
+ ctx.editor.echo_multiline(items)
683
887
  end
684
888
 
685
889
  def buffer_next(ctx, count:, bang:, **)
890
+ count = normalized_count(count)
686
891
  target = ctx.editor.current_buffer.id
687
892
  count.times { target = ctx.editor.next_buffer_id_from(target, 1) }
688
893
  switch_buffer_id(ctx, target, bang:)
689
894
  end
690
895
 
691
896
  def buffer_prev(ctx, count:, bang:, **)
897
+ count = normalized_count(count)
692
898
  target = ctx.editor.current_buffer.id
693
899
  count.times { target = ctx.editor.next_buffer_id_from(target, -1) }
694
900
  switch_buffer_id(ctx, target, bang:)
@@ -768,7 +974,7 @@ module RuVim
768
974
  when "config"
769
975
  "Config: XDG Ruby DSL at ~/.config/ruvim/init.rb and ftplugin/<filetype>.rb"
770
976
  when "bindings", "keys", "keymap"
771
- "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"
772
978
  when "number", "relativenumber", "ignorecase", "smartcase", "hlsearch", "tabstop", "filetype"
773
979
  option_help_line(key)
774
980
  else
@@ -784,8 +990,17 @@ module RuVim
784
990
  def ex_define_command(ctx, argv:, bang:, **)
785
991
  registry = RuVim::ExCommandRegistry.instance
786
992
  if argv.empty?
787
- items = registry.all.select { |spec| spec.source == :user }.map(&:name)
788
- 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
789
1004
  return
790
1005
  end
791
1006
 
@@ -912,14 +1127,33 @@ module RuVim
912
1127
  end
913
1128
 
914
1129
  def ex_commands(ctx, **)
915
- items = RuVim::ExCommandRegistry.instance.all.map do |spec|
1130
+ rows = RuVim::ExCommandRegistry.instance.all.map do |spec|
916
1131
  alias_text = spec.aliases.empty? ? "" : " (#{spec.aliases.join(', ')})"
917
1132
  source = spec.source == :user ? " [user]" : ""
918
- "#{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
919
1143
  end
920
1144
  ctx.editor.show_help_buffer!(title: "[Commands]", lines: ["Ex commands", "", *items])
921
1145
  end
922
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
+
923
1157
  def ex_set(ctx, argv:, **)
924
1158
  ex_set_common(ctx, argv, scope: :auto)
925
1159
  end
@@ -942,6 +1176,7 @@ module RuVim
942
1176
  end
943
1177
 
944
1178
  ctx.editor.set_quickfix_list(items)
1179
+ ctx.editor.select_quickfix(0)
945
1180
  ctx.editor.jump_to_location(ctx.editor.current_quickfix_item)
946
1181
  ctx.editor.echo("quickfix: #{items.length} item(s)")
947
1182
  end
@@ -956,6 +1191,7 @@ module RuVim
956
1191
  end
957
1192
 
958
1193
  ctx.editor.set_location_list(items, window_id: ctx.window.id)
1194
+ ctx.editor.select_location_list(0, window_id: ctx.window.id)
959
1195
  ctx.editor.jump_to_location(ctx.editor.current_location_list_item(ctx.window.id))
960
1196
  ctx.editor.echo("location list: #{items.length} item(s)")
961
1197
  end
@@ -975,6 +1211,7 @@ module RuVim
975
1211
  return
976
1212
  end
977
1213
  ctx.editor.jump_to_location(item)
1214
+ refresh_list_window(ctx.editor, :quickfix)
978
1215
  ctx.editor.echo(quickfix_item_echo(ctx.editor))
979
1216
  end
980
1217
 
@@ -985,6 +1222,7 @@ module RuVim
985
1222
  return
986
1223
  end
987
1224
  ctx.editor.jump_to_location(item)
1225
+ refresh_list_window(ctx.editor, :quickfix)
988
1226
  ctx.editor.echo(quickfix_item_echo(ctx.editor))
989
1227
  end
990
1228
 
@@ -1004,6 +1242,7 @@ module RuVim
1004
1242
  return
1005
1243
  end
1006
1244
  ctx.editor.jump_to_location(item)
1245
+ refresh_list_window(ctx.editor, :location_list)
1007
1246
  ctx.editor.echo(location_item_echo(ctx.editor, ctx.window.id))
1008
1247
  end
1009
1248
 
@@ -1014,37 +1253,89 @@ module RuVim
1014
1253
  return
1015
1254
  end
1016
1255
  ctx.editor.jump_to_location(item)
1256
+ refresh_list_window(ctx.editor, :location_list)
1017
1257
  ctx.editor.echo(location_item_echo(ctx.editor, ctx.window.id))
1018
1258
  end
1019
1259
 
1020
- 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, **)
1021
1261
  materialize_intro_buffer_if_needed(ctx)
1022
- regex = compile_search_regex(pattern, editor: ctx.editor, window: ctx.window, buffer: ctx.buffer)
1023
- changed = 0
1024
- new_lines = ctx.buffer.lines.map do |line|
1025
- if global
1026
- line.scan(regex) { changed += 1 }
1027
- line.gsub(regex, replacement)
1028
- else
1029
- if line.match?(regex)
1030
- changed += 1
1031
- line.sub(regex, replacement)
1032
- else
1033
- line
1034
- end
1035
- 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
1036
1274
  end
1037
1275
 
1276
+ changed = substitute_range(ctx, regex, replacement, r_start, r_end, flags)
1277
+
1038
1278
  if changed.positive?
1039
- ctx.buffer.begin_change_group
1040
- ctx.buffer.replace_all_lines!(new_lines)
1041
- ctx.buffer.end_change_group
1042
1279
  ctx.editor.echo("#{changed} substitution(s)")
1280
+ elsif flags[:no_error]
1281
+ ctx.editor.echo("Pattern not found: #{pattern}")
1043
1282
  else
1044
1283
  ctx.editor.echo("Pattern not found: #{pattern}")
1045
1284
  end
1046
1285
  end
1047
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
+
1048
1339
  def submit_search(ctx, pattern:, direction:)
1049
1340
  text = pattern.to_s
1050
1341
  if text.empty?
@@ -1060,6 +1351,35 @@ module RuVim
1060
1351
 
1061
1352
  private
1062
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
+
1063
1383
  def parse_vimgrep_pattern(argv)
1064
1384
  raw = Array(argv).join(" ").strip
1065
1385
  raise RuVim::CommandError, "Usage: :vimgrep /pattern/" if raw.empty?
@@ -1071,6 +1391,70 @@ module RuVim
1071
1391
  end
1072
1392
  end
1073
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
+
1074
1458
  def grep_items_for_buffers(buffers, regex)
1075
1459
  Array(buffers).flat_map do |buffer|
1076
1460
  buffer.lines.each_with_index.flat_map do |line, row|
@@ -1136,6 +1520,22 @@ module RuVim
1136
1520
  editor.echo("#{kind} closed")
1137
1521
  end
1138
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
+
1139
1539
  def quickfix_item_echo(editor)
1140
1540
  item = editor.current_quickfix_item
1141
1541
  list_item_echo(editor, item, editor.quickfix_index, editor.quickfix_items.length, label: "qf")
@@ -1408,6 +1808,7 @@ module RuVim
1408
1808
  flat = cursor_to_offset(buffer, row, col)
1409
1809
  idx = flat
1410
1810
  keyword_rx = keyword_char_regex(editor, buffer, window)
1811
+ count = normalized_count(count)
1411
1812
  count.times do
1412
1813
  idx = next_word_start_offset(text, idx, keyword_rx)
1413
1814
  return nil unless idx
@@ -1419,7 +1820,7 @@ module RuVim
1419
1820
  buffer = ctx.buffer
1420
1821
  row = ctx.window.cursor_y
1421
1822
  col = ctx.window.cursor_x
1422
- count = 1 if count.to_i <= 0
1823
+ count = normalized_count(count)
1423
1824
  target = { row:, col: }
1424
1825
  count.times do
1425
1826
  target =
@@ -1460,6 +1861,14 @@ module RuVim
1460
1861
  idx = cursor_to_offset(buffer, row, col)
1461
1862
  n = text.length
1462
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
+
1463
1872
  while idx < n && char_class(text[idx], keyword_rx) == :space
1464
1873
  idx += 1
1465
1874
  end
@@ -1846,6 +2255,30 @@ module RuVim
1846
2255
  ctx.window.clamp_to_buffer(ctx.buffer)
1847
2256
  end
1848
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
+
1849
2282
  def cursor_to_offset(buffer, row, col)
1850
2283
  offset = 0
1851
2284
  row.times { |r| offset += buffer.line_length(r) + 1 }
@@ -1881,6 +2314,7 @@ module RuVim
1881
2314
  lines = text.sub(/\n\z/, "").split("\n", -1)
1882
2315
  return if lines.empty?
1883
2316
 
2317
+ count = normalized_count(count)
1884
2318
  insert_at = before ? ctx.window.cursor_y : (ctx.window.cursor_y + 1)
1885
2319
  ctx.buffer.begin_change_group
1886
2320
  count.times { ctx.buffer.insert_lines_at(insert_at, lines) }
@@ -1956,6 +2390,16 @@ module RuVim
1956
2390
  nil
1957
2391
  end
1958
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
+
1959
2403
  def maybe_autowrite_before_switch(ctx)
1960
2404
  return false unless ctx.editor.effective_option("autowrite", window: ctx.window, buffer: ctx.buffer)
1961
2405
  return false unless ctx.buffer.file_buffer?
@@ -1972,7 +2416,7 @@ module RuVim
1972
2416
  return nil if line.empty?
1973
2417
 
1974
2418
  x = [[window.cursor_x, 0].max, [line.length - 1, 0].max].min
1975
- file_char = /[[:alnum:]_\.\/~-]/
2419
+ file_char = /[[:alnum:]_\.\/~:-]/
1976
2420
  if line[x] !~ file_char
1977
2421
  left = x - 1
1978
2422
  right = x + 1
@@ -1992,6 +2436,25 @@ module RuVim
1992
2436
  line[s...e]
1993
2437
  end
1994
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
+
1995
2458
  def resolve_gf_path(ctx, token)
1996
2459
  candidates = gf_candidate_paths(ctx, token.to_s)
1997
2460
  candidates.find { |p| File.file?(p) || File.directory?(p) }
@@ -2063,7 +2526,7 @@ module RuVim
2063
2526
  items = editor.option_snapshot(window: ctx.window, buffer: ctx.buffer).map do |opt|
2064
2527
  format_option_value(opt[:name], opt[:effective])
2065
2528
  end
2066
- ctx.editor.echo(items.join(" "))
2529
+ ctx.editor.echo_multiline(items)
2067
2530
  return
2068
2531
  end
2069
2532
 
@@ -2211,7 +2674,235 @@ module RuVim
2211
2674
  ]
2212
2675
  end
2213
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
+
2214
2904
  def paste_charwise(ctx, text, before:, count:)
2905
+ count = normalized_count(count)
2215
2906
  y = ctx.window.cursor_y
2216
2907
  x = ctx.window.cursor_x
2217
2908
  insert_col = before ? x : [x + 1, ctx.buffer.line_length(y)].min
@@ -2242,7 +2933,7 @@ module RuVim
2242
2933
  end
2243
2934
 
2244
2935
  def move_to_search(ctx, pattern:, direction:, count:)
2245
- count = 1 if count.to_i <= 0
2936
+ count = normalized_count(count)
2246
2937
  regex = compile_search_regex(pattern, editor: ctx.editor, window: ctx.window, buffer: ctx.buffer)
2247
2938
  count.times do
2248
2939
  match = find_next_match(ctx.buffer, ctx.window, regex, direction: direction)
@@ -2330,5 +3021,141 @@ module RuVim
2330
3021
  rescue StandardError
2331
3022
  0
2332
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
2333
3160
  end
2334
3161
  end