ruvim 0.4.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 (113) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +53 -4
  3. data/README.md +15 -6
  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 +3 -2
  10. data/docs/command.md +81 -9
  11. data/docs/done.md +23 -0
  12. data/docs/spec.md +105 -19
  13. data/docs/todo.md +9 -0
  14. data/docs/tutorial.md +9 -1
  15. data/docs/vim_diff.md +13 -0
  16. data/ext/ruvim/extconf.rb +5 -0
  17. data/ext/ruvim/ruvim_ext.c +519 -0
  18. data/lib/ruvim/app.rb +217 -2778
  19. data/lib/ruvim/browser.rb +104 -0
  20. data/lib/ruvim/buffer.rb +39 -28
  21. data/lib/ruvim/command_invocation.rb +2 -2
  22. data/lib/ruvim/completion_manager.rb +708 -0
  23. data/lib/ruvim/dispatcher.rb +14 -8
  24. data/lib/ruvim/display_width.rb +91 -45
  25. data/lib/ruvim/editor.rb +64 -81
  26. data/lib/ruvim/ex_command_registry.rb +3 -1
  27. data/lib/ruvim/gh/link.rb +207 -0
  28. data/lib/ruvim/git/blame.rb +16 -6
  29. data/lib/ruvim/git/branch.rb +20 -5
  30. data/lib/ruvim/git/grep.rb +107 -0
  31. data/lib/ruvim/git/handler.rb +42 -1
  32. data/lib/ruvim/global_commands.rb +175 -35
  33. data/lib/ruvim/highlighter.rb +4 -13
  34. data/lib/ruvim/key_handler.rb +1510 -0
  35. data/lib/ruvim/keymap_manager.rb +7 -7
  36. data/lib/ruvim/lang/base.rb +5 -0
  37. data/lib/ruvim/lang/c.rb +116 -0
  38. data/lib/ruvim/lang/cpp.rb +107 -0
  39. data/lib/ruvim/lang/csv.rb +4 -1
  40. data/lib/ruvim/lang/diff.rb +2 -0
  41. data/lib/ruvim/lang/dockerfile.rb +36 -0
  42. data/lib/ruvim/lang/elixir.rb +85 -0
  43. data/lib/ruvim/lang/erb.rb +30 -0
  44. data/lib/ruvim/lang/go.rb +83 -0
  45. data/lib/ruvim/lang/html.rb +34 -0
  46. data/lib/ruvim/lang/javascript.rb +83 -0
  47. data/lib/ruvim/lang/json.rb +6 -0
  48. data/lib/ruvim/lang/lua.rb +76 -0
  49. data/lib/ruvim/lang/makefile.rb +36 -0
  50. data/lib/ruvim/lang/markdown.rb +3 -4
  51. data/lib/ruvim/lang/ocaml.rb +77 -0
  52. data/lib/ruvim/lang/perl.rb +91 -0
  53. data/lib/ruvim/lang/python.rb +85 -0
  54. data/lib/ruvim/lang/registry.rb +102 -0
  55. data/lib/ruvim/lang/ruby.rb +7 -0
  56. data/lib/ruvim/lang/rust.rb +95 -0
  57. data/lib/ruvim/lang/scheme.rb +5 -0
  58. data/lib/ruvim/lang/sh.rb +76 -0
  59. data/lib/ruvim/lang/sql.rb +52 -0
  60. data/lib/ruvim/lang/toml.rb +36 -0
  61. data/lib/ruvim/lang/tsv.rb +4 -1
  62. data/lib/ruvim/lang/typescript.rb +53 -0
  63. data/lib/ruvim/lang/yaml.rb +62 -0
  64. data/lib/ruvim/rich_view/table_renderer.rb +3 -3
  65. data/lib/ruvim/rich_view.rb +14 -7
  66. data/lib/ruvim/screen.rb +126 -72
  67. data/lib/ruvim/stream/file_load.rb +85 -0
  68. data/lib/ruvim/stream/follow.rb +40 -0
  69. data/lib/ruvim/stream/git.rb +43 -0
  70. data/lib/ruvim/stream/run.rb +74 -0
  71. data/lib/ruvim/stream/stdin.rb +55 -0
  72. data/lib/ruvim/stream.rb +35 -0
  73. data/lib/ruvim/stream_mixer.rb +394 -0
  74. data/lib/ruvim/terminal.rb +18 -4
  75. data/lib/ruvim/text_metrics.rb +84 -65
  76. data/lib/ruvim/version.rb +1 -1
  77. data/lib/ruvim/window.rb +5 -5
  78. data/lib/ruvim.rb +23 -6
  79. data/test/app_command_test.rb +382 -0
  80. data/test/app_completion_test.rb +43 -19
  81. data/test/app_dot_repeat_test.rb +27 -3
  82. data/test/app_ex_command_test.rb +154 -0
  83. data/test/app_motion_test.rb +13 -12
  84. data/test/app_register_test.rb +2 -1
  85. data/test/app_scenario_test.rb +15 -10
  86. data/test/app_startup_test.rb +70 -27
  87. data/test/app_text_object_test.rb +2 -1
  88. data/test/app_unicode_behavior_test.rb +3 -2
  89. data/test/browser_test.rb +88 -0
  90. data/test/buffer_test.rb +24 -0
  91. data/test/cli_test.rb +63 -0
  92. data/test/command_invocation_test.rb +33 -0
  93. data/test/config_dsl_test.rb +47 -0
  94. data/test/dispatcher_test.rb +74 -4
  95. data/test/ex_command_registry_test.rb +106 -0
  96. data/test/follow_test.rb +20 -21
  97. data/test/gh_link_test.rb +141 -0
  98. data/test/git_blame_test.rb +96 -17
  99. data/test/git_grep_test.rb +64 -0
  100. data/test/highlighter_test.rb +125 -0
  101. data/test/indent_test.rb +137 -0
  102. data/test/input_screen_integration_test.rb +1 -1
  103. data/test/keyword_chars_test.rb +85 -0
  104. data/test/lang_test.rb +634 -0
  105. data/test/markdown_renderer_test.rb +5 -5
  106. data/test/on_save_hook_test.rb +12 -8
  107. data/test/render_snapshot_test.rb +78 -0
  108. data/test/rich_view_test.rb +42 -42
  109. data/test/run_command_test.rb +307 -0
  110. data/test/screen_test.rb +68 -5
  111. data/test/stream_test.rb +165 -0
  112. data/test/window_test.rb +59 -0
  113. metadata +52 -2
@@ -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])
@@ -774,12 +774,18 @@ module RuVim
774
774
  ctx.editor.clear_message
775
775
  end
776
776
 
777
- def file_write(ctx, argv:, bang:, **)
777
+ def file_write(ctx, argv:, bang:, kwargs: {}, **)
778
778
  if ctx.buffer.kind == :git_commit
779
779
  git_commit_execute(ctx)
780
780
  return
781
781
  end
782
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
+
783
789
  path = argv[0]
784
790
  target = ctx.buffer.write_to(path)
785
791
  size = File.exist?(target) ? File.size(target) : 0
@@ -790,6 +796,24 @@ module RuVim
790
796
  end
791
797
  end
792
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
+
793
817
  def app_quit(ctx, bang:, **)
794
818
  if ctx.buffer.kind == :filter
795
819
  saved_y = ctx.buffer.options["filter_source_cursor_y"]
@@ -1097,8 +1121,8 @@ module RuVim
1097
1121
  errf.flush
1098
1122
  outf.rewind
1099
1123
  errf.rewind
1100
- stdout_text = outf.read.to_s
1101
- stderr_text = errf.read.to_s
1124
+ stdout_text = outf.read
1125
+ stderr_text = errf.read
1102
1126
  end
1103
1127
  end
1104
1128
  if !stdout_text.empty? || !stderr_text.empty?
@@ -1134,44 +1158,155 @@ module RuVim
1134
1158
  $stderr = (defined?(original_g_stderr) && original_g_stderr) ? original_g_stderr : STDERR
1135
1159
  end
1136
1160
 
1137
- def ex_shell(ctx, command:, **)
1138
- 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
1139
1164
 
1140
- cmd = command.to_s
1141
- 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
1142
1176
 
1143
- shell = ENV["SHELL"].to_s
1144
- shell = "/bin/sh" if shell.empty?
1145
- 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
1146
1181
 
1147
- if !stdout_text.to_s.empty? || !stderr_text.to_s.empty?
1148
- lines = ["Shell output", "", "[command]", cmd, ""]
1149
- unless stdout_text.to_s.empty?
1150
- lines << "[stdout]"
1151
- lines.concat(stdout_text.to_s.lines(chomp: true))
1152
- lines << ""
1153
- end
1154
- unless stderr_text.to_s.empty?
1155
- lines << "[stderr]"
1156
- lines.concat(stderr_text.to_s.lines(chomp: true))
1157
- lines << ""
1158
- end
1159
- lines << "[status]"
1160
- lines << "exit #{status.exitstatus}"
1161
- 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!([""])
1162
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)
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)
1163
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
1164
1269
  end
1165
1270
  rescue Errno::ENOENT => e
1166
1271
  raise RuVim::CommandError, "Shell error: #{e.message}"
1167
1272
  end
1168
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
+
1169
1304
  def ex_commands(ctx, **)
1170
1305
  rows = RuVim::ExCommandRegistry.instance.all.map do |spec|
1171
1306
  alias_text = spec.aliases.empty? ? "" : " (#{spec.aliases.join(', ')})"
1172
1307
  source = spec.source == :user ? " [user]" : ""
1173
1308
  name = "#{spec.name}#{alias_text}#{source}"
1174
- desc = spec.desc.to_s
1309
+ desc = spec.desc
1175
1310
  keys = ex_command_binding_labels(ctx.editor, spec)
1176
1311
  [name, desc, keys]
1177
1312
  end
@@ -1497,13 +1632,18 @@ module RuVim
1497
1632
  end
1498
1633
 
1499
1634
  def run_external_grep(ctx, argv:, target:)
1500
- 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)
1501
1640
  raise RuVim::CommandError, "Usage: :grep pattern [files...]" if args.empty?
1502
1641
 
1503
1642
  grepprg = ctx.editor.effective_option("grepprg", window: ctx.window, buffer: ctx.buffer) || "grep -n"
1504
- cmd = "#{grepprg} #{args}"
1643
+ cmd_parts = Shellwords.shellsplit(grepprg)
1644
+ expanded_args = args.flat_map { |a| (g = Dir.glob(a)).empty? ? [a] : g }
1505
1645
 
1506
- stdout, stderr, status = Open3.capture3(cmd)
1646
+ stdout, stderr, status = Open3.capture3(*cmd_parts, *expanded_args)
1507
1647
  if stdout.strip.empty? && !status.success?
1508
1648
  msg = stderr.strip.empty? ? "No matches found" : stderr.strip
1509
1649
  ctx.editor.echo_error(msg)
@@ -2945,7 +3085,7 @@ module RuVim
2945
3085
  operator_pending: 5,
2946
3086
  command_line: 6
2947
3087
  }
2948
- order.fetch(mode.to_sym, 99)
3088
+ order.fetch(mode, 99)
2949
3089
  end
2950
3090
 
2951
3091
  def sort_binding_entries(entries, sort:)
@@ -3028,7 +3168,7 @@ module RuVim
3028
3168
  lhs = format_binding_tokens(entry.tokens)
3029
3169
  if entry.scope == :global
3030
3170
  "global:#{lhs}"
3031
- elsif entry.mode && entry.mode.to_sym != :normal
3171
+ elsif entry.mode && entry.mode != :normal
3032
3172
  "#{entry.mode}:#{lhs}"
3033
3173
  else
3034
3174
  lhs
@@ -3063,7 +3203,7 @@ module RuVim
3063
3203
  end
3064
3204
 
3065
3205
  def invert_direction(direction)
3066
- direction.to_sym == :forward ? :backward : :forward
3206
+ direction == :forward ? :backward : :forward
3067
3207
  end
3068
3208
 
3069
3209
  def move_to_search(ctx, pattern:, direction:, count:)
@@ -3084,7 +3224,7 @@ module RuVim
3084
3224
  def find_next_match(buffer, window, regex, direction:)
3085
3225
  return nil unless regex
3086
3226
 
3087
- if direction.to_sym == :backward
3227
+ if direction == :backward
3088
3228
  find_backward_match(buffer, window, regex)
3089
3229
  else
3090
3230
  find_forward_match(buffer, window, regex)
@@ -13,20 +13,11 @@ module RuVim
13
13
 
14
14
  def color_columns(filetype, line)
15
15
  ft = filetype.to_s
16
- text = line.to_s
17
- return {} if text.empty?
16
+ return {} if line.empty?
18
17
 
19
- case ft
20
- when "ruby"
21
- Lang::Ruby.color_columns(text)
22
- when "json", "jsonl"
23
- Lang::Json.color_columns(text)
24
- when "markdown"
25
- Lang::Markdown.color_columns(text)
26
- when "scheme"
27
- Lang::Scheme.color_columns(text)
28
- when "diff"
29
- Lang::Diff.color_columns(text)
18
+ entry = Lang::Registry[ft]
19
+ if entry && entry[:mod].respond_to?(:color_columns)
20
+ entry[:mod].color_columns(line)
30
21
  else
31
22
  {}
32
23
  end