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,6 +1,7 @@
1
1
  require_relative "test_helper"
2
2
  require "tempfile"
3
3
  require "stringio"
4
+ require "tmpdir"
4
5
 
5
6
  class AppStartupTest < Minitest::Test
6
7
  def test_startup_ex_command_runs_after_boot
@@ -107,6 +108,34 @@ class AppStartupTest < Minitest::Test
107
108
  end
108
109
  end
109
110
 
111
+ def test_startup_horizontal_split_focuses_first_file
112
+ Tempfile.create(["ruvim-a", ".txt"]) do |a|
113
+ Tempfile.create(["ruvim-b", ".txt"]) do |b|
114
+ a.write("a\n"); a.flush
115
+ b.write("b\n"); b.flush
116
+
117
+ app = RuVim::App.new(paths: [a.path, b.path], clean: true, startup_open_layout: :horizontal)
118
+ editor = app.instance_variable_get(:@editor)
119
+
120
+ assert_equal a.path, editor.current_buffer.path
121
+ end
122
+ end
123
+ end
124
+
125
+ def test_startup_vertical_split_focuses_first_file
126
+ Tempfile.create(["ruvim-a", ".txt"]) do |a|
127
+ Tempfile.create(["ruvim-b", ".txt"]) do |b|
128
+ a.write("a\n"); a.flush
129
+ b.write("b\n"); b.flush
130
+
131
+ app = RuVim::App.new(paths: [a.path, b.path], clean: true, startup_open_layout: :vertical)
132
+ editor = app.instance_variable_get(:@editor)
133
+
134
+ assert_equal a.path, editor.current_buffer.path
135
+ end
136
+ end
137
+ end
138
+
110
139
  def test_startup_tab_layout_opens_multiple_tabs
111
140
  Tempfile.create(["ruvim-a", ".txt"]) do |a|
112
141
  Tempfile.create(["ruvim-b", ".txt"]) do |b|
@@ -121,6 +150,21 @@ class AppStartupTest < Minitest::Test
121
150
  end
122
151
  end
123
152
 
153
+ def test_startup_tab_layout_focuses_first_tab
154
+ Tempfile.create(["ruvim-a", ".txt"]) do |a|
155
+ Tempfile.create(["ruvim-b", ".txt"]) do |b|
156
+ a.write("a\n"); a.flush
157
+ b.write("b\n"); b.flush
158
+
159
+ app = RuVim::App.new(paths: [a.path, b.path], clean: true, startup_open_layout: :tab)
160
+ editor = app.instance_variable_get(:@editor)
161
+
162
+ assert_equal 1, editor.current_tabpage_number
163
+ assert_equal a.path, editor.current_buffer.path
164
+ end
165
+ end
166
+ end
167
+
124
168
  def test_restricted_mode_disables_ex_ruby
125
169
  app = RuVim::App.new(clean: true, restricted: true)
126
170
  editor = app.instance_variable_get(:@editor)
@@ -206,4 +250,147 @@ class AppStartupTest < Minitest::Test
206
250
  assert_match(/startup_actions\.done/, text)
207
251
  end
208
252
  end
253
+
254
+ def test_stdin_stream_mode_uses_stream_buffer_and_appends_input
255
+ app = RuVim::App.new(
256
+ clean: true,
257
+ stdin: StringIO.new("line1\nline2\n"),
258
+ ui_stdin: StringIO.new,
259
+ stdin_stream_mode: true
260
+ )
261
+ editor = app.instance_variable_get(:@editor)
262
+
263
+ 20.times do
264
+ app.send(:drain_stream_events!)
265
+ thread = app.instance_variable_get(:@stream_reader_thread)
266
+ break unless thread&.alive?
267
+ sleep 0.005
268
+ end
269
+
270
+ buf = editor.current_buffer
271
+ assert_equal :stream, buf.kind
272
+ assert_equal "[stdin]", buf.display_name
273
+ assert_equal true, buf.readonly?
274
+ assert_equal false, buf.modifiable?
275
+ assert_includes [:live, :closed], buf.stream_state
276
+ assert_equal ["line1", "line2", ""], buf.lines
277
+ assert_match(/\[stdin\] (follow|EOF)/, editor.message)
278
+ ensure
279
+ app&.send(:shutdown_stream_reader!)
280
+ end
281
+
282
+ def test_large_file_threshold_uses_async_loader
283
+ prev = ENV["RUVIM_ASYNC_FILE_THRESHOLD_BYTES"]
284
+ app = nil
285
+ Tempfile.create(["ruvim-large-async", ".txt"]) do |f|
286
+ begin
287
+ f.write("a\nb\nc\n")
288
+ f.flush
289
+
290
+ ENV["RUVIM_ASYNC_FILE_THRESHOLD_BYTES"] = "1"
291
+ app = RuVim::App.new(clean: true)
292
+ editor = app.instance_variable_get(:@editor)
293
+
294
+ buf = editor.open_path(f.path)
295
+ assert_match(/loading/i, editor.message)
296
+ assert_includes [:live, :closed], buf.loading_state
297
+ assert_equal false, buf.modifiable?
298
+
299
+ 100.times do
300
+ app.send(:drain_stream_events!)
301
+ break if buf.loading_state != :live
302
+ state = app.instance_variable_get(:@async_file_loads)[buf.id]
303
+ break unless state && state[:thread]&.alive?
304
+ sleep 0.005
305
+ end
306
+ app.send(:drain_stream_events!)
307
+
308
+ assert_equal :closed, buf.loading_state
309
+ assert_equal true, buf.modifiable?
310
+ assert_equal %w[a b c], buf.lines
311
+ assert_match(/#{Regexp.escape(f.path)}/, editor.message)
312
+ ensure
313
+ ENV["RUVIM_ASYNC_FILE_THRESHOLD_BYTES"] = prev
314
+ app&.send(:shutdown_background_readers!)
315
+ end
316
+ end
317
+ end
318
+
319
+ def test_very_large_file_shows_prefix_then_appends_rest
320
+ prev_async = ENV["RUVIM_ASYNC_FILE_THRESHOLD_BYTES"]
321
+ prev_prefix = ENV["RUVIM_ASYNC_FILE_PREFIX_BYTES"]
322
+ app = nil
323
+ Tempfile.create(["ruvim-large-prefix", ".txt"]) do |f|
324
+ begin
325
+ f.write("ab\ncd\nef\n")
326
+ f.flush
327
+
328
+ ENV["RUVIM_ASYNC_FILE_THRESHOLD_BYTES"] = "1"
329
+ ENV["RUVIM_ASYNC_FILE_PREFIX_BYTES"] = "4"
330
+ app = RuVim::App.new(clean: true)
331
+ editor = app.instance_variable_get(:@editor)
332
+
333
+ buf = editor.open_path(f.path)
334
+ assert_equal :live, buf.loading_state
335
+ assert_equal ["ab", "c"], buf.lines
336
+ assert_match(/showing first/i, editor.message)
337
+
338
+ 100.times do
339
+ app.send(:drain_stream_events!)
340
+ break if buf.loading_state != :live
341
+ sleep 0.005
342
+ end
343
+ app.send(:drain_stream_events!)
344
+
345
+ assert_equal :closed, buf.loading_state
346
+ assert_equal %w[ab cd ef], buf.lines
347
+ ensure
348
+ ENV["RUVIM_ASYNC_FILE_THRESHOLD_BYTES"] = prev_async
349
+ ENV["RUVIM_ASYNC_FILE_PREFIX_BYTES"] = prev_prefix
350
+ app&.send(:shutdown_background_readers!)
351
+ end
352
+ end
353
+ end
354
+
355
+ def test_command_line_history_persists_in_xdg_state_home
356
+ Dir.mktmpdir("ruvim-history-xdg") do |dir|
357
+ prev_xdg = ENV["XDG_STATE_HOME"]
358
+ prev_home = ENV["HOME"]
359
+ ENV["XDG_STATE_HOME"] = dir
360
+ ENV["HOME"] = dir
361
+
362
+ app1 = RuVim::App.new(clean: true)
363
+ app1.send(:push_command_line_history, ":", "set number")
364
+ app1.send(:push_command_line_history, "/", "foo")
365
+ app1.send(:push_command_line_history, "?", "bar")
366
+ app1.send(:save_command_line_history!)
367
+
368
+ path = File.join(dir, "ruvim", "history.json")
369
+ assert_equal true, File.file?(path)
370
+
371
+ app2 = RuVim::App.new(clean: true)
372
+ hist = app2.instance_variable_get(:@cmdline_history)
373
+ assert_equal ["set number"], hist[":"]
374
+ assert_equal ["foo"], hist["/"]
375
+ assert_equal ["bar"], hist["?"]
376
+ ensure
377
+ ENV["XDG_STATE_HOME"] = prev_xdg
378
+ ENV["HOME"] = prev_home
379
+ end
380
+ end
381
+
382
+ def test_command_line_history_path_falls_back_to_home_dot_ruvim
383
+ Dir.mktmpdir("ruvim-history-home") do |dir|
384
+ prev_xdg = ENV["XDG_STATE_HOME"]
385
+ prev_home = ENV["HOME"]
386
+ ENV.delete("XDG_STATE_HOME")
387
+ ENV["HOME"] = dir
388
+
389
+ app = RuVim::App.new(clean: true)
390
+ assert_equal File.join(dir, ".ruvim", "history.json"), app.send(:command_line_history_file_path)
391
+ ensure
392
+ ENV["XDG_STATE_HOME"] = prev_xdg
393
+ ENV["HOME"] = prev_home
394
+ end
395
+ end
209
396
  end
@@ -0,0 +1,113 @@
1
+ require "test_helper"
2
+
3
+ class ArglistTest < Minitest::Test
4
+ def setup
5
+ @editor = RuVim::Editor.new
6
+ end
7
+
8
+ def test_initial_arglist_is_empty
9
+ assert_empty @editor.arglist
10
+ assert_equal 0, @editor.arglist_index
11
+ end
12
+
13
+ def test_set_arglist
14
+ paths = %w[file1.txt file2.txt file3.txt]
15
+ @editor.set_arglist(paths)
16
+
17
+ assert_equal paths, @editor.arglist
18
+ assert_equal 0, @editor.arglist_index
19
+ assert_equal "file1.txt", @editor.arglist_current
20
+ end
21
+
22
+ def test_arglist_next
23
+ @editor.set_arglist(%w[file1.txt file2.txt file3.txt])
24
+
25
+ assert_equal "file2.txt", @editor.arglist_next
26
+ assert_equal 1, @editor.arglist_index
27
+
28
+ assert_equal "file3.txt", @editor.arglist_next
29
+ assert_equal 2, @editor.arglist_index
30
+
31
+ error = assert_raises(RuVim::CommandError) do
32
+ @editor.arglist_next
33
+ end
34
+ assert_equal "Already at last argument", error.message
35
+ end
36
+
37
+ def test_arglist_prev
38
+ @editor.set_arglist(%w[file1.txt file2.txt file3.txt])
39
+ @editor.arglist_next(2) # Move to index 2
40
+
41
+ assert_equal "file2.txt", @editor.arglist_prev
42
+ assert_equal 1, @editor.arglist_index
43
+
44
+ assert_equal "file1.txt", @editor.arglist_prev
45
+ assert_equal 0, @editor.arglist_index
46
+
47
+ error = assert_raises(RuVim::CommandError) do
48
+ @editor.arglist_prev
49
+ end
50
+ assert_equal "Already at first argument", error.message
51
+ end
52
+
53
+ def test_arglist_first
54
+ @editor.set_arglist(%w[file1.txt file2.txt file3.txt])
55
+ @editor.arglist_next(2) # Move to index 2
56
+
57
+ assert_equal "file1.txt", @editor.arglist_first
58
+ assert_equal 0, @editor.arglist_index
59
+ end
60
+
61
+ def test_arglist_last
62
+ @editor.set_arglist(%w[file1.txt file2.txt file3.txt])
63
+
64
+ assert_equal "file3.txt", @editor.arglist_last
65
+ assert_equal 2, @editor.arglist_index
66
+ end
67
+
68
+ def test_arglist_next_with_count
69
+ @editor.set_arglist(%w[file1.txt file2.txt file3.txt file4.txt])
70
+
71
+ assert_equal "file3.txt", @editor.arglist_next(2)
72
+ assert_equal 2, @editor.arglist_index
73
+ end
74
+
75
+ def test_arglist_prev_with_count
76
+ @editor.set_arglist(%w[file1.txt file2.txt file3.txt file4.txt])
77
+ @editor.arglist_last
78
+
79
+ assert_equal "file2.txt", @editor.arglist_prev(2)
80
+ assert_equal 1, @editor.arglist_index
81
+ end
82
+
83
+ def test_arglist_empty_operations
84
+ assert_nil @editor.arglist_current
85
+ assert_nil @editor.arglist_first
86
+ assert_nil @editor.arglist_last
87
+ end
88
+
89
+ def test_startup_multiple_files_creates_buffers
90
+ # Simulate what open_startup_paths! does without layout option:
91
+ # all files should be loaded as buffers so they appear in :ls
92
+ paths = [File.expand_path("../../t.md", __FILE__),
93
+ File.expand_path("../../t.rb", __FILE__)]
94
+ existing = paths.select { |p| File.exist?(p) }
95
+ skip "need t.md and t.rb in project root" if existing.length < 2
96
+
97
+ @editor.ensure_bootstrap_buffer!
98
+ @editor.set_arglist(paths)
99
+
100
+ # Open first file (displayed in current window)
101
+ first_buf = @editor.add_buffer_from_file(paths[0])
102
+ @editor.switch_to_buffer(first_buf.id)
103
+
104
+ # Load remaining files as buffers (not displayed, but registered)
105
+ paths[1..].each { |p| @editor.add_buffer_from_file(p) }
106
+
107
+ # All files should be in the buffer list
108
+ all_paths = @editor.buffer_ids.map { |id| @editor.buffers[id].path }
109
+ paths.each do |p|
110
+ assert_includes all_paths, p, "#{p} should appear in buffer list"
111
+ end
112
+ end
113
+ end
data/test/buffer_test.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require_relative "test_helper"
2
+ require "tmpdir"
2
3
 
3
4
  class BufferTest < Minitest::Test
4
5
  def test_insert_and_undo_redo_group
@@ -25,48 +26,66 @@ class BufferTest < Minitest::Test
25
26
  end
26
27
 
27
28
  def test_reload_from_file_clears_modified_and_history
28
- path = "/tmp/ruvim_buffer_reload_test.txt"
29
- File.write(path, "one\n")
30
- b = RuVim::Buffer.from_file(id: 1, path: path)
31
- b.insert_char(0, 0, "X")
32
- assert b.modified?
33
- assert b.can_undo?
29
+ Dir.mktmpdir do |dir|
30
+ path = File.join(dir, "ruvim_buffer_reload_test.txt")
31
+ File.write(path, "one\n")
32
+ b = RuVim::Buffer.from_file(id: 1, path: path)
33
+ b.insert_char(0, 0, "X")
34
+ assert b.modified?
35
+ assert b.can_undo?
34
36
 
35
- b.reload_from_file!
36
- assert_equal ["one"], b.lines
37
- refute b.modified?
38
- refute b.can_undo?
37
+ b.reload_from_file!
38
+ assert_equal ["one"], b.lines
39
+ refute b.modified?
40
+ refute b.can_undo?
41
+ end
39
42
  end
40
43
 
41
44
  def test_utf8_file_is_loaded_as_utf8_text_not_binary_bytes
42
- path = "/tmp/ruvim_utf8_test.txt"
43
- File.binwrite(path, "bar 日本語 編集\n".encode("UTF-8"))
45
+ Dir.mktmpdir do |dir|
46
+ path = File.join(dir, "ruvim_utf8_test.txt")
47
+ File.binwrite(path, "bar 日本語 編集\n".encode("UTF-8"))
44
48
 
45
- b = RuVim::Buffer.from_file(id: 1, path: path)
46
- assert_equal Encoding::UTF_8, b.line_at(0).encoding
47
- assert_equal "bar 日本語 編集", b.line_at(0)
48
- assert_equal 10, b.line_length(0)
49
+ b = RuVim::Buffer.from_file(id: 1, path: path)
50
+ assert_equal Encoding::UTF_8, b.line_at(0).encoding
51
+ assert_equal "bar 日本語 編集", b.line_at(0)
52
+ assert_equal 10, b.line_length(0)
53
+ end
49
54
  end
50
55
 
51
56
  def test_invalid_bytes_are_decoded_to_valid_utf8_with_replacement
52
- path = "/tmp/ruvim_invalid_bytes_test.txt"
53
- File.binwrite(path, "A\xFFB\n".b)
57
+ Dir.mktmpdir do |dir|
58
+ path = File.join(dir, "ruvim_invalid_bytes_test.txt")
59
+ File.binwrite(path, "A\xFFB\n".b)
54
60
 
55
- b = RuVim::Buffer.from_file(id: 1, path: path)
56
- line = b.line_at(0)
57
- assert_equal Encoding::UTF_8, line.encoding
58
- assert_equal true, line.valid_encoding?
59
- assert_match(/A.*B/, line)
61
+ b = RuVim::Buffer.from_file(id: 1, path: path)
62
+ line = b.line_at(0)
63
+ assert_equal Encoding::UTF_8, line.encoding
64
+ assert_equal true, line.valid_encoding?
65
+ assert_match(/A.*B/, line)
66
+ end
60
67
  end
61
68
 
62
69
  def test_write_to_saves_utf8_text
63
- path = "/tmp/ruvim_write_utf8_test.txt"
64
- b = RuVim::Buffer.new(id: 1, lines: ["日本語", "abc"])
65
- b.write_to(path)
70
+ Dir.mktmpdir do |dir|
71
+ path = File.join(dir, "ruvim_write_utf8_test.txt")
72
+ b = RuVim::Buffer.new(id: 1, lines: ["日本語", "abc"])
73
+ b.write_to(path)
74
+
75
+ data = File.binread(path)
76
+ text = data.force_encoding(Encoding::UTF_8)
77
+ assert_equal true, text.valid_encoding?
78
+ assert_equal "日本語\nabc", text
79
+ end
80
+ end
66
81
 
67
- data = File.binread(path)
68
- text = data.force_encoding(Encoding::UTF_8)
69
- assert_equal true, text.valid_encoding?
70
- assert_equal "日本語\nabc", text
82
+ def test_append_stream_text_updates_lines_without_marking_modified
83
+ b = RuVim::Buffer.new(id: 1, lines: [""])
84
+ b.append_stream_text!("a\n")
85
+ b.append_stream_text!("b")
86
+ b.append_stream_text!("\n\nc\n")
87
+
88
+ assert_equal ["a", "b", "", "c", ""], b.lines
89
+ refute b.modified?
71
90
  end
72
91
  end