ruvim 0.3.0 → 0.6.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 (129) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +68 -7
  3. data/README.md +30 -7
  4. data/Rakefile +7 -0
  5. data/benchmark/cext_compare.rb +165 -0
  6. data/benchmark/chunked_load.rb +256 -0
  7. data/benchmark/file_load.rb +140 -0
  8. data/benchmark/hotspots.rb +178 -0
  9. data/docs/binding.md +18 -1
  10. data/docs/command.md +156 -10
  11. data/docs/config.md +10 -2
  12. data/docs/done.md +23 -0
  13. data/docs/spec.md +162 -25
  14. data/docs/todo.md +9 -0
  15. data/docs/tutorial.md +33 -1
  16. data/docs/vim_diff.md +31 -8
  17. data/ext/ruvim/extconf.rb +5 -0
  18. data/ext/ruvim/ruvim_ext.c +519 -0
  19. data/lib/ruvim/app.rb +246 -2525
  20. data/lib/ruvim/browser.rb +104 -0
  21. data/lib/ruvim/buffer.rb +43 -20
  22. data/lib/ruvim/cli.rb +6 -0
  23. data/lib/ruvim/command_invocation.rb +2 -2
  24. data/lib/ruvim/completion_manager.rb +708 -0
  25. data/lib/ruvim/dispatcher.rb +14 -8
  26. data/lib/ruvim/display_width.rb +91 -45
  27. data/lib/ruvim/editor.rb +74 -80
  28. data/lib/ruvim/ex_command_registry.rb +3 -1
  29. data/lib/ruvim/file_watcher.rb +243 -0
  30. data/lib/ruvim/gh/link.rb +207 -0
  31. data/lib/ruvim/git/blame.rb +255 -0
  32. data/lib/ruvim/git/branch.rb +112 -0
  33. data/lib/ruvim/git/commit.rb +102 -0
  34. data/lib/ruvim/git/diff.rb +129 -0
  35. data/lib/ruvim/git/grep.rb +107 -0
  36. data/lib/ruvim/git/handler.rb +125 -0
  37. data/lib/ruvim/git/log.rb +41 -0
  38. data/lib/ruvim/git/status.rb +103 -0
  39. data/lib/ruvim/global_commands.rb +351 -77
  40. data/lib/ruvim/highlighter.rb +4 -11
  41. data/lib/ruvim/input.rb +1 -0
  42. data/lib/ruvim/key_handler.rb +1510 -0
  43. data/lib/ruvim/keymap_manager.rb +7 -7
  44. data/lib/ruvim/lang/base.rb +5 -0
  45. data/lib/ruvim/lang/c.rb +116 -0
  46. data/lib/ruvim/lang/cpp.rb +107 -0
  47. data/lib/ruvim/lang/csv.rb +4 -1
  48. data/lib/ruvim/lang/diff.rb +43 -0
  49. data/lib/ruvim/lang/dockerfile.rb +36 -0
  50. data/lib/ruvim/lang/elixir.rb +85 -0
  51. data/lib/ruvim/lang/erb.rb +30 -0
  52. data/lib/ruvim/lang/go.rb +83 -0
  53. data/lib/ruvim/lang/html.rb +34 -0
  54. data/lib/ruvim/lang/javascript.rb +83 -0
  55. data/lib/ruvim/lang/json.rb +40 -0
  56. data/lib/ruvim/lang/lua.rb +76 -0
  57. data/lib/ruvim/lang/makefile.rb +36 -0
  58. data/lib/ruvim/lang/markdown.rb +3 -4
  59. data/lib/ruvim/lang/ocaml.rb +77 -0
  60. data/lib/ruvim/lang/perl.rb +91 -0
  61. data/lib/ruvim/lang/python.rb +85 -0
  62. data/lib/ruvim/lang/registry.rb +102 -0
  63. data/lib/ruvim/lang/ruby.rb +7 -0
  64. data/lib/ruvim/lang/rust.rb +95 -0
  65. data/lib/ruvim/lang/scheme.rb +5 -0
  66. data/lib/ruvim/lang/sh.rb +76 -0
  67. data/lib/ruvim/lang/sql.rb +52 -0
  68. data/lib/ruvim/lang/toml.rb +36 -0
  69. data/lib/ruvim/lang/tsv.rb +4 -1
  70. data/lib/ruvim/lang/typescript.rb +53 -0
  71. data/lib/ruvim/lang/yaml.rb +62 -0
  72. data/lib/ruvim/rich_view/json_renderer.rb +131 -0
  73. data/lib/ruvim/rich_view/jsonl_renderer.rb +57 -0
  74. data/lib/ruvim/rich_view/table_renderer.rb +3 -3
  75. data/lib/ruvim/rich_view.rb +30 -7
  76. data/lib/ruvim/screen.rb +135 -84
  77. data/lib/ruvim/stream/file_load.rb +85 -0
  78. data/lib/ruvim/stream/follow.rb +40 -0
  79. data/lib/ruvim/stream/git.rb +43 -0
  80. data/lib/ruvim/stream/run.rb +74 -0
  81. data/lib/ruvim/stream/stdin.rb +55 -0
  82. data/lib/ruvim/stream.rb +35 -0
  83. data/lib/ruvim/stream_mixer.rb +394 -0
  84. data/lib/ruvim/terminal.rb +18 -4
  85. data/lib/ruvim/text_metrics.rb +84 -65
  86. data/lib/ruvim/version.rb +1 -1
  87. data/lib/ruvim/window.rb +5 -5
  88. data/lib/ruvim.rb +31 -4
  89. data/test/app_command_test.rb +382 -0
  90. data/test/app_completion_test.rb +65 -16
  91. data/test/app_dot_repeat_test.rb +27 -3
  92. data/test/app_ex_command_test.rb +154 -0
  93. data/test/app_motion_test.rb +13 -12
  94. data/test/app_register_test.rb +2 -1
  95. data/test/app_scenario_test.rb +182 -8
  96. data/test/app_startup_test.rb +70 -27
  97. data/test/app_text_object_test.rb +2 -1
  98. data/test/app_unicode_behavior_test.rb +3 -2
  99. data/test/browser_test.rb +88 -0
  100. data/test/buffer_test.rb +24 -0
  101. data/test/cli_test.rb +77 -0
  102. data/test/clipboard_test.rb +67 -0
  103. data/test/command_invocation_test.rb +33 -0
  104. data/test/command_line_test.rb +118 -0
  105. data/test/config_dsl_test.rb +134 -0
  106. data/test/dispatcher_test.rb +74 -4
  107. data/test/display_width_test.rb +41 -0
  108. data/test/ex_command_registry_test.rb +106 -0
  109. data/test/file_watcher_test.rb +197 -0
  110. data/test/follow_test.rb +198 -0
  111. data/test/gh_link_test.rb +141 -0
  112. data/test/git_blame_test.rb +792 -0
  113. data/test/git_grep_test.rb +64 -0
  114. data/test/highlighter_test.rb +169 -0
  115. data/test/indent_test.rb +223 -0
  116. data/test/input_screen_integration_test.rb +1 -1
  117. data/test/keyword_chars_test.rb +85 -0
  118. data/test/lang_test.rb +634 -0
  119. data/test/markdown_renderer_test.rb +5 -5
  120. data/test/on_save_hook_test.rb +12 -8
  121. data/test/render_snapshot_test.rb +78 -0
  122. data/test/rich_view_test.rb +279 -23
  123. data/test/run_command_test.rb +307 -0
  124. data/test/screen_test.rb +68 -5
  125. data/test/search_option_test.rb +19 -0
  126. data/test/stream_test.rb +165 -0
  127. data/test/test_helper.rb +9 -0
  128. data/test/window_test.rb +59 -0
  129. metadata +68 -2
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module RuVim
6
+ module Git
7
+ module Status
8
+ module_function
9
+
10
+ # Run git status.
11
+ # Returns [lines, root, error_message].
12
+ def run(file_path)
13
+ root, err = Git.repo_root(file_path)
14
+ return [nil, nil, err] unless root
15
+
16
+ out, err, status = Open3.capture3("git", "status", chdir: root)
17
+ unless status.success?
18
+ return [nil, nil, err.strip]
19
+ end
20
+ [out.lines(chomp: true), root, nil]
21
+ end
22
+
23
+ # Extract filename from a git status output line.
24
+ # Returns relative path or nil.
25
+ def parse_filename(line)
26
+ stripped = line.to_s.strip
27
+ case stripped
28
+ when /\A(?:modified|new file|deleted|renamed|copied|typechange):\s+(.+)/
29
+ $1.strip
30
+ when /\A(\S.+)/
31
+ # Untracked file lines (no prefix keyword)
32
+ path = $1.strip
33
+ # Skip section headers and hints
34
+ return nil if path.start_with?("(")
35
+ return nil if path.match?(/\A[A-Z]/)
36
+ path
37
+ else
38
+ nil
39
+ end
40
+ end
41
+
42
+ # Command handler methods
43
+ module HandlerMethods
44
+ def git_status(ctx, **)
45
+ file_path = git_resolve_path(ctx)
46
+ unless file_path
47
+ ctx.editor.echo_error("No file or directory to resolve git repo")
48
+ return
49
+ end
50
+
51
+ lines, root, err = Status.run(file_path)
52
+ unless lines
53
+ ctx.editor.echo_error("git status: #{err}")
54
+ return
55
+ end
56
+
57
+ buf = ctx.editor.add_virtual_buffer(
58
+ kind: :git_status,
59
+ name: "[Git Status]",
60
+ lines: lines,
61
+ readonly: true,
62
+ modifiable: false
63
+ )
64
+ buf.options["git_repo_root"] = root
65
+ ctx.editor.switch_to_buffer(buf.id)
66
+ bind_git_buffer_keys(ctx.editor, buf.id)
67
+ ctx.editor.echo("[Git Status]")
68
+ end
69
+
70
+ def git_status_open_file(ctx, **)
71
+ buf = ctx.buffer
72
+ unless buf.kind == :git_status
73
+ ctx.editor.echo_error("Not a git status buffer")
74
+ return
75
+ end
76
+
77
+ line = buf.line_at(ctx.window.cursor_y)
78
+ filename = Status.parse_filename(line)
79
+ unless filename
80
+ ctx.editor.echo_error("No file on this line")
81
+ return
82
+ end
83
+
84
+ root = buf.options["git_repo_root"]
85
+ full_path = File.join(root, filename)
86
+ unless File.exist?(full_path)
87
+ ctx.editor.echo_error("File not found: #{filename}")
88
+ return
89
+ end
90
+
91
+ existing = ctx.editor.buffers.values.find { |b| b.path == full_path }
92
+ if existing
93
+ ctx.editor.switch_to_buffer(existing.id)
94
+ else
95
+ new_buf = ctx.editor.add_buffer_from_file(full_path)
96
+ ctx.editor.switch_to_buffer(new_buf.id)
97
+ end
98
+ end
99
+
100
+ end
101
+ end
102
+ end
103
+ end
@@ -366,7 +366,7 @@ module RuVim
366
366
  chunk = char_at_cursor_for_delete(ctx.buffer, ctx.window.cursor_y, ctx.window.cursor_x)
367
367
  ok = ctx.buffer.delete_char(ctx.window.cursor_y, ctx.window.cursor_x)
368
368
  break unless ok
369
- deleted << chunk.to_s
369
+ deleted << chunk
370
370
  end
371
371
  ctx.buffer.end_change_group
372
372
  store_delete_register(ctx, text: deleted, type: :charwise) unless deleted.empty?
@@ -391,7 +391,7 @@ module RuVim
391
391
  break if x >= line.length
392
392
 
393
393
  ch = line[x]
394
- swapped = ch.to_s.swapcase
394
+ swapped = ch.swapcase
395
395
  if !swapped.empty? && swapped != ch
396
396
  ctx.buffer.delete_span(y, x, y, x + 1)
397
397
  ctx.buffer.insert_char(y, x, swapped[0])
@@ -467,6 +467,8 @@ module RuVim
467
467
  when "k" then delete_lines_up(ctx, ncount)
468
468
  when "$" then delete_to_end_of_line(ctx)
469
469
  when "w" then delete_word_forward(ctx, ncount)
470
+ when "G" then delete_lines_to_end(ctx)
471
+ when "gg" then delete_lines_to_start(ctx)
470
472
  when "iw" then delete_text_object_word(ctx, around: false)
471
473
  when "aw" then delete_text_object_word(ctx, around: true)
472
474
  else
@@ -478,9 +480,22 @@ module RuVim
478
480
 
479
481
  def change_motion(ctx, count:, kwargs:, **)
480
482
  materialize_intro_buffer_if_needed(ctx)
481
- handled = delete_motion(ctx, count:, kwargs:)
482
- return unless handled
483
+ motion = (kwargs[:motion] || kwargs["motion"]).to_s
484
+ result = delete_motion(ctx, count:, kwargs:)
485
+ return unless result
483
486
 
487
+ if result == :linewise
488
+ case motion
489
+ when "G"
490
+ y = ctx.buffer.lines.length
491
+ ctx.buffer.insert_lines_at(y, [""])
492
+ ctx.window.cursor_y = y
493
+ when "gg"
494
+ ctx.buffer.insert_lines_at(0, [""])
495
+ ctx.window.cursor_y = 0
496
+ end
497
+ ctx.window.cursor_x = 0
498
+ end
484
499
  enter_insert_mode(ctx)
485
500
  end
486
501
 
@@ -610,6 +625,10 @@ module RuVim
610
625
  text = ctx.buffer.span_text(y, x, target[:row], target[:col])
611
626
  store_yank_register(ctx, text:, type: :charwise)
612
627
  ctx.editor.echo("yanked")
628
+ when "G"
629
+ yank_lines_to_end(ctx)
630
+ when "gg"
631
+ yank_lines_to_start(ctx)
613
632
  when "iw"
614
633
  yank_text_object_word(ctx, around: false)
615
634
  when "aw"
@@ -755,7 +774,18 @@ module RuVim
755
774
  ctx.editor.clear_message
756
775
  end
757
776
 
758
- def file_write(ctx, argv:, bang:, **)
777
+ def file_write(ctx, argv:, bang:, kwargs: {}, **)
778
+ if ctx.buffer.kind == :git_commit
779
+ git_commit_execute(ctx)
780
+ return
781
+ end
782
+
783
+ arg = argv.join(" ")
784
+ if arg.start_with?("!")
785
+ file_write_to_shell(ctx, command: arg[1..].strip, kwargs: kwargs)
786
+ return
787
+ end
788
+
759
789
  path = argv[0]
760
790
  target = ctx.buffer.write_to(path)
761
791
  size = File.exist?(target) ? File.size(target) : 0
@@ -766,7 +796,41 @@ module RuVim
766
796
  end
767
797
  end
768
798
 
799
+ def file_write_to_shell(ctx, command:, kwargs: {})
800
+ raise RuVim::CommandError, "Usage: :w !<command>" if command.empty?
801
+
802
+ r_start = kwargs[:range_start] || 0
803
+ r_end = kwargs[:range_end] || (ctx.buffer.line_count - 1)
804
+ lines = (r_start..r_end).map { |i| ctx.buffer.lines[i] }
805
+ input = lines.join("\n") + "\n"
806
+
807
+ shell = ENV["SHELL"].to_s
808
+ shell = "/bin/sh" if shell.empty?
809
+ _stdout, stderr_text, status = Open3.capture3(shell, "-c", command, stdin_data: input)
810
+ unless stderr_text.empty?
811
+ ctx.editor.echo_error(stderr_text.lines(chomp: true).first)
812
+ return
813
+ end
814
+ ctx.editor.echo("#{lines.length} line(s) written to !#{command}, exit #{status.exitstatus}")
815
+ end
816
+
769
817
  def app_quit(ctx, bang:, **)
818
+ if ctx.buffer.kind == :filter
819
+ saved_y = ctx.buffer.options["filter_source_cursor_y"]
820
+ saved_x = ctx.buffer.options["filter_source_cursor_x"]
821
+ saved_row_offset = ctx.buffer.options["filter_source_row_offset"]
822
+ saved_col_offset = ctx.buffer.options["filter_source_col_offset"]
823
+ ctx.editor.delete_buffer(ctx.buffer.id)
824
+ if saved_y
825
+ win = ctx.editor.current_window
826
+ win.cursor_y = saved_y
827
+ win.cursor_x = saved_x || 0
828
+ win.row_offset = saved_row_offset || 0
829
+ win.col_offset = saved_col_offset || 0
830
+ end
831
+ return
832
+ end
833
+
770
834
  if ctx.editor.window_count > 1
771
835
  ctx.editor.close_current_window
772
836
  ctx.editor.echo("closed window")
@@ -1057,8 +1121,8 @@ module RuVim
1057
1121
  errf.flush
1058
1122
  outf.rewind
1059
1123
  errf.rewind
1060
- stdout_text = outf.read.to_s
1061
- stderr_text = errf.read.to_s
1124
+ stdout_text = outf.read
1125
+ stderr_text = errf.read
1062
1126
  end
1063
1127
  end
1064
1128
  if !stdout_text.empty? || !stderr_text.empty?
@@ -1094,44 +1158,155 @@ module RuVim
1094
1158
  $stderr = (defined?(original_g_stderr) && original_g_stderr) ? original_g_stderr : STDERR
1095
1159
  end
1096
1160
 
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?
1161
+ def ex_run(ctx, argv:, **)
1162
+ editor = ctx.editor
1163
+ source_buffer = ctx.buffer
1099
1164
 
1100
- cmd = command.to_s
1101
- raise RuVim::CommandError, "Usage: :!<command>" if cmd.strip.empty?
1165
+ if argv.empty?
1166
+ # No args: use last command for this buffer, or runprg
1167
+ command = editor.run_history[source_buffer.id]
1168
+ if command.nil?
1169
+ runprg = editor.get_option("runprg", buffer: source_buffer)
1170
+ raise RuVim::CommandError, "No runprg set and no previous :run command" unless runprg
1171
+ command = runprg
1172
+ end
1173
+ else
1174
+ command = argv.first
1175
+ end
1102
1176
 
1103
- shell = ENV["SHELL"].to_s
1104
- shell = "/bin/sh" if shell.empty?
1105
- stdout_text, stderr_text, status = Open3.capture3(shell, "-c", cmd)
1177
+ # Auto-save modified buffer before running
1178
+ if source_buffer.modified? && source_buffer.path
1179
+ source_buffer.write_to
1180
+ end
1106
1181
 
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")
1182
+ expanded = expand_run_command(command, source_buffer)
1183
+ editor.run_history[source_buffer.id] = command
1184
+
1185
+ # Find or create [Shell Output] buffer
1186
+ output_buf = if editor.run_output_buffer_id
1187
+ editor.buffers[editor.run_output_buffer_id]
1188
+ end
1189
+
1190
+ if output_buf
1191
+ # Reuse: clear content
1192
+ output_buf.replace_all_lines!([""])
1193
+ else
1194
+ output_buf = editor.add_virtual_buffer(
1195
+ kind: :run_output,
1196
+ name: "[Shell Output]",
1197
+ lines: [""],
1198
+ readonly: true,
1199
+ modifiable: false
1200
+ )
1201
+ editor.run_output_buffer_id = output_buf.id
1202
+ end
1203
+ # Open output buffer in a split (reuse existing window if present)
1204
+ existing_win = editor.windows.values.find { |w| w.buffer_id == output_buf.id }
1205
+ if existing_win
1206
+ editor.current_window_id = existing_win.id
1207
+ else
1208
+ win = editor.split_current_window(layout: :horizontal, place: :after)
1209
+ win.buffer_id = output_buf.id
1210
+ win.cursor_x = 0
1211
+ win.cursor_y = 0
1212
+ win.row_offset = 0
1213
+ end
1214
+
1215
+ # Start streaming (handler creates Stream::Run and sets buf.stream)
1216
+ handler = editor.run_stream_handler
1217
+ if handler
1218
+ handler.call(output_buf, expanded)
1122
1219
  else
1220
+ # Fallback for tests without stream handler: synchronous execution
1221
+ shell = ENV["SHELL"].to_s
1222
+ shell = "/bin/sh" if shell.empty?
1223
+ output, _status = Open3.capture2e(shell, "-c", expanded)
1224
+ output_buf.replace_all_lines!(output.lines(chomp: true))
1225
+ end
1226
+ end
1227
+
1228
+ def expand_run_command(command, buffer)
1229
+ return command unless command.include?("%")
1230
+
1231
+ path = buffer.path
1232
+ raise RuVim::CommandError, "No file name (use % in :run requires a file)" unless path
1233
+
1234
+ command.gsub("%", path)
1235
+ end
1236
+
1237
+ def ex_shell(ctx, command:, **)
1238
+ raise RuVim::CommandError, "Restricted mode: :! is disabled" if ctx.editor.respond_to?(:restricted_mode?) && ctx.editor.restricted_mode?
1239
+
1240
+ raise RuVim::CommandError, "Usage: :!<command>" if command.strip.empty?
1241
+
1242
+ executor = ctx.editor.shell_executor
1243
+ if executor
1244
+ status = executor.call(command)
1123
1245
  ctx.editor.echo("shell exit #{status.exitstatus}")
1246
+ else
1247
+ shell = ENV["SHELL"].to_s
1248
+ shell = "/bin/sh" if shell.empty?
1249
+ stdout_text, stderr_text, status = Open3.capture3(shell, "-c", command)
1250
+
1251
+ if !stdout_text.empty? || !stderr_text.empty?
1252
+ lines = ["Shell output", "", "[command]", command, ""]
1253
+ unless stdout_text.empty?
1254
+ lines << "[stdout]"
1255
+ lines.concat(stdout_text.lines(chomp: true))
1256
+ lines << ""
1257
+ end
1258
+ unless stderr_text.empty?
1259
+ lines << "[stderr]"
1260
+ lines.concat(stderr_text.lines(chomp: true))
1261
+ lines << ""
1262
+ end
1263
+ lines << "[status]"
1264
+ lines << "exit #{status.exitstatus}"
1265
+ ctx.editor.show_help_buffer!(title: "[Shell Output]", lines:, filetype: "sh")
1266
+ else
1267
+ ctx.editor.echo("shell exit #{status.exitstatus}")
1268
+ end
1124
1269
  end
1125
1270
  rescue Errno::ENOENT => e
1126
1271
  raise RuVim::CommandError, "Shell error: #{e.message}"
1127
1272
  end
1128
1273
 
1274
+ def ex_read(ctx, argv:, kwargs:, **)
1275
+ arg = argv.join(" ")
1276
+ raise RuVim::CommandError, "Usage: :r[ead] [file] or :r[ead] !command" if arg.strip.empty?
1277
+
1278
+ insert_line = kwargs[:range_start] || ctx.window.cursor_y
1279
+ new_lines = if arg.start_with?("!")
1280
+ command = arg[1..].strip
1281
+ raise RuVim::CommandError, "Usage: :r !<command>" if command.empty?
1282
+
1283
+ shell = ENV["SHELL"].to_s
1284
+ shell = "/bin/sh" if shell.empty?
1285
+ stdout_text, stderr_text, _status = Open3.capture3(shell, "-c", command)
1286
+ unless stderr_text.empty?
1287
+ ctx.editor.echo_error(stderr_text.lines(chomp: true).first)
1288
+ end
1289
+ stdout_text.lines(chomp: true)
1290
+ else
1291
+ path = File.expand_path(arg.strip)
1292
+ raise RuVim::CommandError, "File not found: #{arg.strip}" unless File.exist?(path)
1293
+
1294
+ File.read(path).lines(chomp: true)
1295
+ end
1296
+
1297
+ return if new_lines.empty?
1298
+
1299
+ ctx.buffer.insert_lines_at(insert_line + 1, new_lines)
1300
+ ctx.window.cursor_y = insert_line + new_lines.length
1301
+ ctx.editor.echo("#{new_lines.length} line(s) inserted")
1302
+ end
1303
+
1129
1304
  def ex_commands(ctx, **)
1130
1305
  rows = RuVim::ExCommandRegistry.instance.all.map do |spec|
1131
1306
  alias_text = spec.aliases.empty? ? "" : " (#{spec.aliases.join(', ')})"
1132
1307
  source = spec.source == :user ? " [user]" : ""
1133
1308
  name = "#{spec.name}#{alias_text}#{source}"
1134
- desc = spec.desc.to_s
1309
+ desc = spec.desc
1135
1310
  keys = ex_command_binding_labels(ctx.editor, spec)
1136
1311
  [name, desc, keys]
1137
1312
  end
@@ -1336,6 +1511,69 @@ module RuVim
1336
1511
  RuVim::RichView.toggle!(ctx.editor)
1337
1512
  end
1338
1513
 
1514
+ def rich_view_close_buffer(ctx, **)
1515
+ ctx.editor.delete_buffer(ctx.buffer.id)
1516
+ end
1517
+
1518
+ def search_filter(ctx, **)
1519
+ editor = ctx.editor
1520
+ search = editor.last_search
1521
+ unless search
1522
+ editor.echo_error("No search pattern")
1523
+ return
1524
+ end
1525
+
1526
+ regex = compile_search_regex(search[:pattern], editor: editor, window: ctx.window, buffer: ctx.buffer)
1527
+ source_buffer = ctx.buffer
1528
+
1529
+ # Collect matching lines with origin mapping
1530
+ origins = []
1531
+ matching_lines = []
1532
+ source_buffer.lines.each_with_index do |line, row|
1533
+ if regex.match?(line)
1534
+ # If source is a filter buffer, chain back to the original
1535
+ if source_buffer.kind == :filter && source_buffer.options["filter_origins"]
1536
+ origins << source_buffer.options["filter_origins"][row]
1537
+ else
1538
+ origins << { buffer_id: source_buffer.id, row: row }
1539
+ end
1540
+ matching_lines << line
1541
+ end
1542
+ end
1543
+
1544
+ if matching_lines.empty?
1545
+ editor.echo_error("Pattern not found: #{search[:pattern]}")
1546
+ return
1547
+ end
1548
+
1549
+ filetype = source_buffer.options["filetype"]
1550
+ filter_buf = editor.add_virtual_buffer(
1551
+ kind: :filter,
1552
+ name: "[Filter: /#{search[:pattern]}/]",
1553
+ lines: matching_lines,
1554
+ filetype: filetype,
1555
+ readonly: false,
1556
+ modifiable: false
1557
+ )
1558
+ filter_buf.options["filter_origins"] = origins
1559
+ filter_buf.options["filter_source_buffer_id"] = source_buffer.id
1560
+ filter_buf.options["filter_source_cursor_y"] = ctx.window.cursor_y
1561
+ filter_buf.options["filter_source_cursor_x"] = ctx.window.cursor_x
1562
+ filter_buf.options["filter_source_row_offset"] = ctx.window.row_offset
1563
+ filter_buf.options["filter_source_col_offset"] = ctx.window.col_offset
1564
+ editor.switch_to_buffer(filter_buf.id)
1565
+ editor.echo("filter: #{matching_lines.length} line(s)")
1566
+ end
1567
+
1568
+ def ex_filter(ctx, argv:, **)
1569
+ if argv.any?
1570
+ pattern = parse_vimgrep_pattern(argv.join(" "))
1571
+ editor = ctx.editor
1572
+ editor.set_last_search(pattern: pattern, direction: :forward)
1573
+ end
1574
+ search_filter(ctx)
1575
+ end
1576
+
1339
1577
  def submit_search(ctx, pattern:, direction:)
1340
1578
  text = pattern.to_s
1341
1579
  if text.empty?
@@ -1349,6 +1587,8 @@ module RuVim
1349
1587
  move_to_search(ctx, pattern: text, direction:, count: 1)
1350
1588
  end
1351
1589
 
1590
+ include RuVim::Git::Handler
1591
+
1352
1592
  private
1353
1593
 
1354
1594
  def reindent_range(ctx, start_row, end_row)
@@ -1392,13 +1632,18 @@ module RuVim
1392
1632
  end
1393
1633
 
1394
1634
  def run_external_grep(ctx, argv:, target:)
1395
- args = Array(argv).join(" ").strip
1635
+ if ctx.editor.respond_to?(:restricted_mode?) && ctx.editor.restricted_mode?
1636
+ raise RuVim::CommandError, "Restricted mode: :grep is disabled"
1637
+ end
1638
+
1639
+ args = Array(argv)
1396
1640
  raise RuVim::CommandError, "Usage: :grep pattern [files...]" if args.empty?
1397
1641
 
1398
1642
  grepprg = ctx.editor.effective_option("grepprg", window: ctx.window, buffer: ctx.buffer) || "grep -n"
1399
- cmd = "#{grepprg} #{args}"
1643
+ cmd_parts = Shellwords.shellsplit(grepprg)
1644
+ expanded_args = args.flat_map { |a| (g = Dir.glob(a)).empty? ? [a] : g }
1400
1645
 
1401
- stdout, stderr, status = Open3.capture3(cmd)
1646
+ stdout, stderr, status = Open3.capture3(*cmd_parts, *expanded_args)
1402
1647
  if stdout.strip.empty? && !status.success?
1403
1648
  msg = stderr.strip.empty? ? "No matches found" : stderr.strip
1404
1649
  ctx.editor.echo_error(msg)
@@ -1723,6 +1968,48 @@ module RuVim
1723
1968
  true
1724
1969
  end
1725
1970
 
1971
+ def delete_lines_to_end(ctx)
1972
+ y = ctx.window.cursor_y
1973
+ total = ctx.buffer.lines.length - y
1974
+ deleted = ctx.buffer.line_block_text(y, total)
1975
+ ctx.buffer.begin_change_group
1976
+ total.times { ctx.buffer.delete_line(y) }
1977
+ ctx.buffer.end_change_group
1978
+ store_delete_register(ctx, text: deleted, type: :linewise)
1979
+ ctx.window.clamp_to_buffer(ctx.buffer)
1980
+ :linewise
1981
+ end
1982
+
1983
+ def delete_lines_to_start(ctx)
1984
+ y = ctx.window.cursor_y
1985
+ total = y + 1
1986
+ deleted = ctx.buffer.line_block_text(0, total)
1987
+ ctx.buffer.begin_change_group
1988
+ total.times { ctx.buffer.delete_line(0) }
1989
+ ctx.buffer.end_change_group
1990
+ store_delete_register(ctx, text: deleted, type: :linewise)
1991
+ ctx.window.cursor_y = 0
1992
+ ctx.window.cursor_x = 0
1993
+ ctx.window.clamp_to_buffer(ctx.buffer)
1994
+ :linewise
1995
+ end
1996
+
1997
+ def yank_lines_to_end(ctx)
1998
+ y = ctx.window.cursor_y
1999
+ total = ctx.buffer.lines.length - y
2000
+ text = ctx.buffer.line_block_text(y, total)
2001
+ store_yank_register(ctx, text: text, type: :linewise)
2002
+ ctx.editor.echo("#{total} line(s) yanked")
2003
+ end
2004
+
2005
+ def yank_lines_to_start(ctx)
2006
+ y = ctx.window.cursor_y
2007
+ total = y + 1
2008
+ text = ctx.buffer.line_block_text(0, total)
2009
+ store_yank_register(ctx, text: text, type: :linewise)
2010
+ ctx.editor.echo("#{total} line(s) yanked")
2011
+ end
2012
+
1726
2013
  def delete_to_end_of_line(ctx)
1727
2014
  y = ctx.window.cursor_y
1728
2015
  x = ctx.window.cursor_x
@@ -1754,22 +2041,22 @@ module RuVim
1754
2041
  end
1755
2042
 
1756
2043
  def delete_text_object_word(ctx, around:)
1757
- span = word_object_span(ctx.buffer, ctx.window, around:)
1758
- return false unless span
1759
-
1760
- text = ctx.buffer.span_text(span[:start_row], span[:start_col], span[:end_row], span[:end_col])
1761
- ctx.buffer.begin_change_group
1762
- ctx.buffer.delete_span(span[:start_row], span[:start_col], span[:end_row], span[:end_col])
1763
- ctx.buffer.end_change_group
1764
- store_delete_register(ctx, text:, type: :charwise) unless text.empty?
1765
- ctx.window.cursor_y = span[:start_row]
1766
- ctx.window.cursor_x = span[:start_col]
1767
- ctx.window.clamp_to_buffer(ctx.buffer)
1768
- true
2044
+ delete_span(ctx, word_object_span(ctx.buffer, ctx.window, around:))
1769
2045
  end
1770
2046
 
1771
2047
  def delete_text_object(ctx, motion)
1772
- span = text_object_span(ctx.buffer, ctx.window, motion)
2048
+ delete_span(ctx, text_object_span(ctx.buffer, ctx.window, motion))
2049
+ end
2050
+
2051
+ def yank_text_object_word(ctx, around:)
2052
+ yank_span(ctx, word_object_span(ctx.buffer, ctx.window, around:))
2053
+ end
2054
+
2055
+ def yank_text_object(ctx, motion)
2056
+ yank_span(ctx, text_object_span(ctx.buffer, ctx.window, motion))
2057
+ end
2058
+
2059
+ def delete_span(ctx, span)
1773
2060
  return false unless span
1774
2061
 
1775
2062
  text = ctx.buffer.span_text(span[:start_row], span[:start_col], span[:end_row], span[:end_col])
@@ -1783,18 +2070,7 @@ module RuVim
1783
2070
  true
1784
2071
  end
1785
2072
 
1786
- def yank_text_object_word(ctx, around:)
1787
- span = word_object_span(ctx.buffer, ctx.window, around:)
1788
- return false unless span
1789
-
1790
- text = ctx.buffer.span_text(span[:start_row], span[:start_col], span[:end_row], span[:end_col])
1791
- store_yank_register(ctx, text:, type: :charwise) unless text.empty?
1792
- ctx.editor.echo("yanked")
1793
- true
1794
- end
1795
-
1796
- def yank_text_object(ctx, motion)
1797
- span = text_object_span(ctx.buffer, ctx.window, motion)
2073
+ def yank_span(ctx, span)
1798
2074
  return false unless span
1799
2075
 
1800
2076
  text = ctx.buffer.span_text(span[:start_row], span[:start_col], span[:end_row], span[:end_col])
@@ -1979,9 +2255,9 @@ module RuVim
1979
2255
  x = [window.cursor_x, line.length - 1].min
1980
2256
  return nil if x.negative?
1981
2257
 
1982
- left = find_left_quote(line, x, quote)
2258
+ left = find_quote(line, x, quote, :left)
1983
2259
  right_from = [x, (left ? left + 1 : 0)].max
1984
- right = find_right_quote(line, right_from, quote)
2260
+ right = find_quote(line, right_from, quote, :right)
1985
2261
  return nil unless left && right && left < right
1986
2262
 
1987
2263
  if around
@@ -2053,20 +2329,18 @@ module RuVim
2053
2329
  end
2054
2330
  end
2055
2331
 
2056
- def find_left_quote(line, x, quote)
2332
+ def find_quote(line, x, quote, direction)
2057
2333
  i = x
2058
- while i >= 0
2059
- return i if line[i] == quote && !escaped?(line, i)
2060
- i -= 1
2061
- end
2062
- nil
2063
- end
2064
-
2065
- def find_right_quote(line, x, quote)
2066
- i = x
2067
- while i < line.length
2068
- return i if line[i] == quote && !escaped?(line, i)
2069
- i += 1
2334
+ if direction == :left
2335
+ while i >= 0
2336
+ return i if line[i] == quote && !escaped?(line, i)
2337
+ i -= 1
2338
+ end
2339
+ else
2340
+ while i < line.length
2341
+ return i if line[i] == quote && !escaped?(line, i)
2342
+ i += 1
2343
+ end
2070
2344
  end
2071
2345
  nil
2072
2346
  end
@@ -2811,7 +3085,7 @@ module RuVim
2811
3085
  operator_pending: 5,
2812
3086
  command_line: 6
2813
3087
  }
2814
- order.fetch(mode.to_sym, 99)
3088
+ order.fetch(mode, 99)
2815
3089
  end
2816
3090
 
2817
3091
  def sort_binding_entries(entries, sort:)
@@ -2894,7 +3168,7 @@ module RuVim
2894
3168
  lhs = format_binding_tokens(entry.tokens)
2895
3169
  if entry.scope == :global
2896
3170
  "global:#{lhs}"
2897
- elsif entry.mode && entry.mode.to_sym != :normal
3171
+ elsif entry.mode && entry.mode != :normal
2898
3172
  "#{entry.mode}:#{lhs}"
2899
3173
  else
2900
3174
  lhs
@@ -2929,7 +3203,7 @@ module RuVim
2929
3203
  end
2930
3204
 
2931
3205
  def invert_direction(direction)
2932
- direction.to_sym == :forward ? :backward : :forward
3206
+ direction == :forward ? :backward : :forward
2933
3207
  end
2934
3208
 
2935
3209
  def move_to_search(ctx, pattern:, direction:, count:)
@@ -2950,7 +3224,7 @@ module RuVim
2950
3224
  def find_next_match(buffer, window, regex, direction:)
2951
3225
  return nil unless regex
2952
3226
 
2953
- if direction.to_sym == :backward
3227
+ if direction == :backward
2954
3228
  find_backward_match(buffer, window, regex)
2955
3229
  else
2956
3230
  find_forward_match(buffer, window, regex)