ruvim 0.2.0 → 0.4.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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +4 -0
  3. data/AGENTS.md +96 -0
  4. data/CLAUDE.md +1 -0
  5. data/README.md +15 -1
  6. data/docs/binding.md +39 -0
  7. data/docs/command.md +163 -4
  8. data/docs/config.md +12 -4
  9. data/docs/done.md +21 -0
  10. data/docs/spec.md +214 -18
  11. data/docs/todo.md +1 -5
  12. data/docs/tutorial.md +24 -0
  13. data/docs/vim_diff.md +105 -173
  14. data/lib/ruvim/app.rb +1165 -70
  15. data/lib/ruvim/buffer.rb +47 -1
  16. data/lib/ruvim/cli.rb +18 -3
  17. data/lib/ruvim/clipboard.rb +2 -0
  18. data/lib/ruvim/command_invocation.rb +3 -1
  19. data/lib/ruvim/command_line.rb +2 -0
  20. data/lib/ruvim/command_registry.rb +2 -0
  21. data/lib/ruvim/config_dsl.rb +2 -0
  22. data/lib/ruvim/config_loader.rb +2 -0
  23. data/lib/ruvim/context.rb +2 -0
  24. data/lib/ruvim/dispatcher.rb +143 -13
  25. data/lib/ruvim/display_width.rb +3 -0
  26. data/lib/ruvim/editor.rb +466 -71
  27. data/lib/ruvim/ex_command_registry.rb +2 -0
  28. data/lib/ruvim/file_watcher.rb +243 -0
  29. data/lib/ruvim/git/blame.rb +245 -0
  30. data/lib/ruvim/git/branch.rb +97 -0
  31. data/lib/ruvim/git/commit.rb +102 -0
  32. data/lib/ruvim/git/diff.rb +129 -0
  33. data/lib/ruvim/git/handler.rb +84 -0
  34. data/lib/ruvim/git/log.rb +41 -0
  35. data/lib/ruvim/git/status.rb +103 -0
  36. data/lib/ruvim/global_commands.rb +1066 -105
  37. data/lib/ruvim/highlighter.rb +19 -22
  38. data/lib/ruvim/input.rb +40 -28
  39. data/lib/ruvim/keymap_manager.rb +83 -0
  40. data/lib/ruvim/keyword_chars.rb +2 -0
  41. data/lib/ruvim/lang/base.rb +25 -0
  42. data/lib/ruvim/lang/csv.rb +18 -0
  43. data/lib/ruvim/lang/diff.rb +41 -0
  44. data/lib/ruvim/lang/json.rb +52 -0
  45. data/lib/ruvim/lang/markdown.rb +170 -0
  46. data/lib/ruvim/lang/ruby.rb +236 -0
  47. data/lib/ruvim/lang/scheme.rb +44 -0
  48. data/lib/ruvim/lang/tsv.rb +19 -0
  49. data/lib/ruvim/rich_view/json_renderer.rb +131 -0
  50. data/lib/ruvim/rich_view/jsonl_renderer.rb +57 -0
  51. data/lib/ruvim/rich_view/markdown_renderer.rb +248 -0
  52. data/lib/ruvim/rich_view/table_renderer.rb +176 -0
  53. data/lib/ruvim/rich_view.rb +109 -0
  54. data/lib/ruvim/screen.rb +503 -109
  55. data/lib/ruvim/terminal.rb +18 -1
  56. data/lib/ruvim/text_metrics.rb +2 -0
  57. data/lib/ruvim/version.rb +1 -1
  58. data/lib/ruvim/window.rb +2 -0
  59. data/lib/ruvim.rb +24 -0
  60. data/test/app_completion_test.rb +98 -0
  61. data/test/app_dot_repeat_test.rb +13 -0
  62. data/test/app_motion_test.rb +13 -0
  63. data/test/app_scenario_test.rb +898 -1
  64. data/test/app_startup_test.rb +187 -0
  65. data/test/arglist_test.rb +113 -0
  66. data/test/buffer_test.rb +49 -30
  67. data/test/cli_test.rb +14 -0
  68. data/test/clipboard_test.rb +67 -0
  69. data/test/command_line_test.rb +118 -0
  70. data/test/config_dsl_test.rb +87 -0
  71. data/test/dispatcher_test.rb +322 -0
  72. data/test/display_width_test.rb +41 -0
  73. data/test/editor_register_test.rb +23 -0
  74. data/test/file_watcher_test.rb +197 -0
  75. data/test/follow_test.rb +199 -0
  76. data/test/git_blame_test.rb +713 -0
  77. data/test/highlighter_test.rb +165 -0
  78. data/test/indent_test.rb +287 -0
  79. data/test/input_screen_integration_test.rb +40 -2
  80. data/test/markdown_renderer_test.rb +279 -0
  81. data/test/on_save_hook_test.rb +150 -0
  82. data/test/rich_view_test.rb +734 -0
  83. data/test/screen_test.rb +304 -0
  84. data/test/search_option_test.rb +19 -0
  85. data/test/test_helper.rb +9 -0
  86. metadata +49 -2
@@ -1,6 +1,7 @@
1
1
  require_relative "test_helper"
2
2
  require "fileutils"
3
3
  require "tmpdir"
4
+ require "stringio"
4
5
 
5
6
  class AppScenarioTest < Minitest::Test
6
7
  def setup
@@ -78,6 +79,79 @@ class AppScenarioTest < Minitest::Test
78
79
  assert_equal ["bc", "bc", "bc"], @editor.current_buffer.lines
79
80
  end
80
81
 
82
+ def test_s_substitutes_char_and_enters_insert_mode
83
+ @editor.current_buffer.replace_all_lines!(["abcd"])
84
+ @editor.current_window.cursor_x = 1
85
+
86
+ feed("s", "X", :escape)
87
+
88
+ assert_equal ["aXcd"], @editor.current_buffer.lines
89
+ assert_equal :normal, @editor.mode
90
+ end
91
+
92
+ def test_z_commands_reposition_current_line_in_window
93
+ @editor.current_buffer.replace_all_lines!((1..20).map { |i| "line#{i}" })
94
+ @editor.current_window_view_height_hint = 5
95
+ @editor.current_window.cursor_y = 10
96
+
97
+ feed("z", "t")
98
+ assert_equal 10, @editor.current_window.row_offset
99
+
100
+ feed("z", "z")
101
+ assert_equal 8, @editor.current_window.row_offset
102
+
103
+ feed("z", "b")
104
+ assert_equal 6, @editor.current_window.row_offset
105
+ end
106
+
107
+ def test_j_joins_next_line_trimming_indent
108
+ @editor.current_buffer.replace_all_lines!(["foo", " bar", "baz"])
109
+ @editor.current_window.cursor_y = 0
110
+ @editor.current_window.cursor_x = 0
111
+
112
+ feed("J")
113
+
114
+ assert_equal ["foo bar", "baz"], @editor.current_buffer.lines
115
+ assert_equal 3, @editor.current_window.cursor_x
116
+ end
117
+
118
+ def test_uppercase_aliases_d_c_s_x_y_and_tilde
119
+ @editor.current_buffer.replace_all_lines!(["abcd"])
120
+ @editor.current_window.cursor_x = 2
121
+ feed("X")
122
+ assert_equal ["acd"], @editor.current_buffer.lines
123
+
124
+ @editor.current_buffer.replace_all_lines!(["abcd"])
125
+ @editor.current_window.cursor_x = 1
126
+ feed("D")
127
+ assert_equal ["a"], @editor.current_buffer.lines
128
+
129
+ @editor.current_buffer.replace_all_lines!(["abcd"])
130
+ @editor.current_window.cursor_x = 1
131
+ feed("C", "X", :escape)
132
+ assert_equal ["aX"], @editor.current_buffer.lines
133
+ assert_equal :normal, @editor.mode
134
+
135
+ @editor.current_buffer.replace_all_lines!(["Abcd"])
136
+ @editor.current_window.cursor_x = 0
137
+ feed("~")
138
+ assert_equal ["abcd"], @editor.current_buffer.lines
139
+ assert_equal 1, @editor.current_window.cursor_x
140
+
141
+ @editor.current_buffer.replace_all_lines!(["hello"])
142
+ @editor.current_window.cursor_x = 0
143
+ feed("Y")
144
+ reg = @editor.get_register("\"")
145
+ assert_equal :linewise, reg[:type]
146
+ assert_equal "hello\n", reg[:text]
147
+
148
+ @editor.current_buffer.replace_all_lines!(["hello"])
149
+ @editor.current_window.cursor_x = 0
150
+ feed("S", "x", :escape)
151
+ assert_equal ["x"], @editor.current_buffer.lines
152
+ assert_equal :normal, @editor.mode
153
+ end
154
+
81
155
  def test_expandtab_and_autoindent_in_insert_mode
82
156
  @editor.set_option("expandtab", true, scope: :buffer)
83
157
  @editor.set_option("tabstop", 4, scope: :buffer)
@@ -103,6 +177,62 @@ class AppScenarioTest < Minitest::Test
103
177
  assert_equal ["if x {", " "], @editor.current_buffer.lines
104
178
  end
105
179
 
180
+ def test_smartindent_adds_shiftwidth_after_ruby_def
181
+ @editor.set_option("autoindent", true, scope: :buffer)
182
+ @editor.set_option("smartindent", true, scope: :buffer)
183
+ @editor.set_option("shiftwidth", 2, scope: :buffer)
184
+ @editor.assign_filetype(@editor.current_buffer, "ruby")
185
+ @editor.current_buffer.replace_all_lines!(["def foo"])
186
+ @editor.current_window.cursor_y = 0
187
+ @editor.current_window.cursor_x = @editor.current_buffer.line_length(0)
188
+
189
+ feed("A", :enter, :escape)
190
+
191
+ assert_equal ["def foo", " "], @editor.current_buffer.lines
192
+ end
193
+
194
+ def test_smartindent_adds_shiftwidth_after_ruby_do_block
195
+ @editor.set_option("autoindent", true, scope: :buffer)
196
+ @editor.set_option("smartindent", true, scope: :buffer)
197
+ @editor.set_option("shiftwidth", 2, scope: :buffer)
198
+ @editor.assign_filetype(@editor.current_buffer, "ruby")
199
+ @editor.current_buffer.replace_all_lines!([" items.each do |x|"])
200
+ @editor.current_window.cursor_y = 0
201
+ @editor.current_window.cursor_x = @editor.current_buffer.line_length(0)
202
+
203
+ feed("A", :enter, :escape)
204
+
205
+ assert_equal [" items.each do |x|", " "], @editor.current_buffer.lines
206
+ end
207
+
208
+ def test_smartindent_dedents_end_in_insert_mode
209
+ @editor.set_option("autoindent", true, scope: :buffer)
210
+ @editor.set_option("smartindent", true, scope: :buffer)
211
+ @editor.set_option("shiftwidth", 2, scope: :buffer)
212
+ @editor.assign_filetype(@editor.current_buffer, "ruby")
213
+ @editor.current_buffer.replace_all_lines!(["def foo"])
214
+ @editor.current_window.cursor_y = 0
215
+ @editor.current_window.cursor_x = @editor.current_buffer.line_length(0)
216
+
217
+ feed("A", :enter, "b", "a", "r", :enter, "e", "n", "d", :escape)
218
+
219
+ assert_equal ["def foo", " bar", "end"], @editor.current_buffer.lines
220
+ end
221
+
222
+ def test_smartindent_dedents_else_in_insert_mode
223
+ @editor.set_option("autoindent", true, scope: :buffer)
224
+ @editor.set_option("smartindent", true, scope: :buffer)
225
+ @editor.set_option("shiftwidth", 2, scope: :buffer)
226
+ @editor.assign_filetype(@editor.current_buffer, "ruby")
227
+ @editor.current_buffer.replace_all_lines!(["if cond"])
228
+ @editor.current_window.cursor_y = 0
229
+ @editor.current_window.cursor_x = @editor.current_buffer.line_length(0)
230
+
231
+ feed("A", :enter, "a", :enter, "e", "l", "s", "e", :escape)
232
+
233
+ assert_equal ["if cond", " a", "else"], @editor.current_buffer.lines
234
+ end
235
+
106
236
  def test_incsearch_moves_cursor_while_typing_and_escape_restores
107
237
  @editor.set_option("incsearch", true, scope: :global)
108
238
  @editor.current_buffer.replace_all_lines!(["alpha", "beta", "gamma"])
@@ -218,6 +348,87 @@ class AppScenarioTest < Minitest::Test
218
348
  assert_equal [1, 0], [@editor.current_window.cursor_y, @editor.current_window.cursor_x]
219
349
  end
220
350
 
351
+ def test_nomodifiable_buffer_edit_key_does_not_crash
352
+ @editor.current_buffer.replace_all_lines!(["hello"])
353
+ @editor.current_buffer.modifiable = false
354
+ @editor.current_buffer.readonly = true
355
+
356
+ @app.send(:handle_key, "x")
357
+
358
+ assert_equal ["hello"], @editor.current_buffer.lines
359
+ assert_match(/not modifiable/i, @editor.message)
360
+ end
361
+
362
+ def test_nomodifiable_buffer_insert_mode_is_rejected
363
+ @editor.current_buffer.replace_all_lines!(["hello"])
364
+ @editor.current_buffer.modifiable = false
365
+ @editor.current_buffer.readonly = true
366
+
367
+ @app.send(:handle_key, "i")
368
+
369
+ assert_equal :normal, @editor.mode
370
+ assert_equal ["hello"], @editor.current_buffer.lines
371
+ assert_match(/not modifiable/i, @editor.message)
372
+ end
373
+
374
+ def test_normal_ctrl_c_stops_stdin_stream_via_default_binding
375
+ stream = StringIO.new("hello\n")
376
+ @app.instance_variable_set(:@stdin_stream_source, stream)
377
+ @app.send(:prepare_stdin_stream_buffer!)
378
+
379
+ @app.send(:handle_key, :ctrl_c)
380
+
381
+ assert_equal :closed, @editor.current_buffer.stream_state
382
+ assert_equal :normal, @editor.mode
383
+ assert_equal true, stream.closed?
384
+ assert_match(/\[stdin\] closed/, @editor.message)
385
+ end
386
+
387
+ def test_ctrl_z_calls_terminal_suspend
388
+ terminal_stub = Object.new
389
+ terminal_stub.instance_variable_set(:@suspend_calls, 0)
390
+ terminal_stub.define_singleton_method(:suspend_for_tstp) do
391
+ @suspend_calls += 1
392
+ end
393
+ terminal_stub.define_singleton_method(:suspend_calls) { @suspend_calls }
394
+ @app.instance_variable_set(:@terminal, terminal_stub)
395
+
396
+ feed("i", "a", :ctrl_z)
397
+
398
+ assert_equal 1, terminal_stub.suspend_calls
399
+ assert_equal :insert, @editor.mode
400
+ assert_equal ["a"], @editor.current_buffer.lines
401
+ end
402
+
403
+ def test_ctrl_z_invalidates_screen_cache_for_full_redraw_after_fg
404
+ terminal_stub = Object.new
405
+ terminal_stub.define_singleton_method(:suspend_for_tstp) {}
406
+ @app.instance_variable_set(:@terminal, terminal_stub)
407
+
408
+ screen_stub = Object.new
409
+ screen_stub.instance_variable_set(:@invalidated, false)
410
+ screen_stub.define_singleton_method(:invalidate_cache!) do
411
+ @invalidated = true
412
+ end
413
+ screen_stub.define_singleton_method(:invalidated?) { @invalidated }
414
+ @app.instance_variable_set(:@screen, screen_stub)
415
+
416
+ feed(:ctrl_z)
417
+
418
+ assert_equal true, screen_stub.invalidated?
419
+ end
420
+
421
+ def test_g_and_1g_distinguish_implicit_and_explicit_count
422
+ @editor.current_buffer.replace_all_lines!(%w[a b c d])
423
+ @editor.current_window.cursor_y = 1
424
+
425
+ feed("G")
426
+ assert_equal 3, @editor.current_window.cursor_y
427
+
428
+ feed("1", "G")
429
+ assert_equal 0, @editor.current_window.cursor_y
430
+ end
431
+
221
432
  def test_backspace_indent_allows_deleting_autoindent_before_insert_start
222
433
  @editor.set_option("backspace", "indent,eol", scope: :global)
223
434
  @editor.current_buffer.replace_all_lines!([" abc"])
@@ -281,6 +492,23 @@ class AppScenarioTest < Minitest::Test
281
492
  end
282
493
  end
283
494
 
495
+ def test_gf_supports_file_with_line_number_suffix
496
+ Dir.mktmpdir("ruvim-gf-line") do |dir|
497
+ target = File.join(dir, "foo.rb")
498
+ File.write(target, "line1\nline2\nline3\n")
499
+ @editor.current_buffer.path = File.join(dir, "main.txt")
500
+ @editor.current_buffer.replace_all_lines!(["foo.rb:3"])
501
+ @editor.current_window.cursor_y = 0
502
+ @editor.current_window.cursor_x = 3
503
+ @editor.set_option("hidden", true, scope: :global)
504
+
505
+ feed("g", "f")
506
+
507
+ assert_equal File.expand_path(target), File.expand_path(@editor.current_buffer.path)
508
+ assert_equal 2, @editor.current_window.cursor_y
509
+ end
510
+ end
511
+
284
512
  def test_showmatch_message_respects_matchtime_and_clears
285
513
  @editor.set_option("showmatch", true, scope: :global)
286
514
  @editor.set_option("matchtime", 1, scope: :global) # 0.1 sec
@@ -323,7 +551,7 @@ class AppScenarioTest < Minitest::Test
323
551
  @editor.current_buffer.replace_all_lines!(["a", "b"])
324
552
  @editor.current_window.cursor_y = 0
325
553
 
326
- feed("z")
554
+ feed("_")
327
555
  assert @editor.message_error?
328
556
  assert_match(/Unknown key:/, @editor.message)
329
557
 
@@ -344,4 +572,673 @@ class AppScenarioTest < Minitest::Test
344
572
  assert_equal "", @editor.message
345
573
  assert_equal 1, @editor.current_window.cursor_y
346
574
  end
575
+
576
+ # --- hit-enter prompt tests ---
577
+
578
+ def test_ls_with_multiple_buffers_enters_hit_enter_mode
579
+ @editor.add_empty_buffer(path: "second.rb")
580
+ @dispatcher.dispatch_ex(@editor, "ls")
581
+
582
+ assert_equal :hit_enter, @editor.mode
583
+ assert_instance_of Array, @editor.hit_enter_lines
584
+ assert_operator @editor.hit_enter_lines.length, :>=, 2
585
+ end
586
+
587
+ def test_ls_with_single_buffer_uses_normal_echo
588
+ @dispatcher.dispatch_ex(@editor, "ls")
589
+
590
+ refute_equal :hit_enter, @editor.mode
591
+ refute_nil @editor.message
592
+ refute @editor.message.to_s.empty?
593
+ end
594
+
595
+ def test_hit_enter_dismiss_with_enter
596
+ @editor.add_empty_buffer(path: "second.rb")
597
+ @dispatcher.dispatch_ex(@editor, "ls")
598
+ assert_equal :hit_enter, @editor.mode
599
+
600
+ feed(:enter)
601
+
602
+ assert_equal :normal, @editor.mode
603
+ assert_nil @editor.hit_enter_lines
604
+ end
605
+
606
+ def test_hit_enter_dismiss_with_escape
607
+ @editor.add_empty_buffer(path: "second.rb")
608
+ @dispatcher.dispatch_ex(@editor, "ls")
609
+ assert_equal :hit_enter, @editor.mode
610
+
611
+ feed(:escape)
612
+
613
+ assert_equal :normal, @editor.mode
614
+ assert_nil @editor.hit_enter_lines
615
+ end
616
+
617
+ def test_hit_enter_dismiss_with_ctrl_c
618
+ @editor.add_empty_buffer(path: "second.rb")
619
+ @dispatcher.dispatch_ex(@editor, "ls")
620
+ assert_equal :hit_enter, @editor.mode
621
+
622
+ feed(:ctrl_c)
623
+
624
+ assert_equal :normal, @editor.mode
625
+ assert_nil @editor.hit_enter_lines
626
+ end
627
+
628
+ def test_hit_enter_colon_enters_command_line
629
+ @editor.add_empty_buffer(path: "second.rb")
630
+ @dispatcher.dispatch_ex(@editor, "ls")
631
+ assert_equal :hit_enter, @editor.mode
632
+
633
+ feed(":")
634
+
635
+ assert_equal :command_line, @editor.mode
636
+ assert_nil @editor.hit_enter_lines
637
+ end
638
+
639
+ def test_hit_enter_slash_enters_search
640
+ @editor.add_empty_buffer(path: "second.rb")
641
+ @dispatcher.dispatch_ex(@editor, "ls")
642
+ assert_equal :hit_enter, @editor.mode
643
+
644
+ feed("/")
645
+
646
+ assert_equal :command_line, @editor.mode
647
+ assert_equal "/", @editor.command_line_prefix
648
+ assert_nil @editor.hit_enter_lines
649
+ end
650
+
651
+ def test_hit_enter_question_enters_reverse_search
652
+ @editor.add_empty_buffer(path: "second.rb")
653
+ @dispatcher.dispatch_ex(@editor, "ls")
654
+ assert_equal :hit_enter, @editor.mode
655
+
656
+ feed("?")
657
+
658
+ assert_equal :command_line, @editor.mode
659
+ assert_equal "?", @editor.command_line_prefix
660
+ assert_nil @editor.hit_enter_lines
661
+ end
662
+
663
+ def test_args_with_multiple_files_enters_hit_enter_mode
664
+ @editor.set_arglist(["a.rb", "b.rb", "c.rb"])
665
+ @dispatcher.dispatch_ex(@editor, "args")
666
+
667
+ assert_equal :hit_enter, @editor.mode
668
+ assert_instance_of Array, @editor.hit_enter_lines
669
+ assert_equal 3, @editor.hit_enter_lines.length
670
+ assert_match(/\[a\.rb\]/, @editor.hit_enter_lines[0])
671
+ end
672
+
673
+ def test_args_with_single_file_uses_normal_echo
674
+ @editor.set_arglist(["a.rb"])
675
+ @dispatcher.dispatch_ex(@editor, "args")
676
+
677
+ refute_equal :hit_enter, @editor.mode
678
+ end
679
+
680
+ def test_set_no_args_enters_hit_enter_mode
681
+ @dispatcher.dispatch_ex(@editor, "set")
682
+
683
+ # option_snapshot returns many options, so always > 1 line
684
+ assert_equal :hit_enter, @editor.mode
685
+ assert_instance_of Array, @editor.hit_enter_lines
686
+ assert_operator @editor.hit_enter_lines.length, :>, 1
687
+ end
688
+
689
+ def test_batch_insert_handles_pasted_text_correctly
690
+ @editor.current_buffer.replace_all_lines!([""])
691
+ # Simulate pasting "Hello World\n" in insert mode (batch of characters)
692
+ feed("i", *"Hello World".chars, :enter, *"Second line".chars, :escape)
693
+
694
+ assert_equal ["Hello World", "Second line"], @editor.current_buffer.lines
695
+ assert_equal :normal, @editor.mode
696
+ end
697
+
698
+ def test_paste_batch_suppresses_autoindent
699
+ @editor.current_buffer.replace_all_lines!([" hello"])
700
+ @editor.current_window.cursor_y = 0
701
+ @editor.current_window.cursor_x = 0
702
+
703
+ # Normal enter in insert mode should autoindent
704
+ feed("A", :enter, :escape)
705
+ assert_equal [" hello", " "], @editor.current_buffer.lines
706
+
707
+ # Simulate paste batch: autoindent should be suppressed
708
+ @editor.current_buffer.replace_all_lines!([" hello"])
709
+ @editor.current_window.cursor_y = 0
710
+ @editor.current_window.cursor_x = 0
711
+ feed("A")
712
+ @app.instance_variable_set(:@paste_batch, true)
713
+ feed(:enter, *"world".chars)
714
+ @app.instance_variable_set(:@paste_batch, false)
715
+ feed(:escape)
716
+
717
+ assert_equal [" hello", "world"], @editor.current_buffer.lines
718
+ end
719
+
720
+ def test_batch_insert_stops_on_escape
721
+ @editor.current_buffer.replace_all_lines!([""])
722
+ # Escape exits insert mode; subsequent keys are normal-mode commands
723
+ feed("i", "a", "b", "c", :escape)
724
+
725
+ assert_equal ["abc"], @editor.current_buffer.lines
726
+ assert_equal :normal, @editor.mode
727
+ end
728
+
729
+ def test_ls_format_shows_vim_style_output
730
+ @editor.add_empty_buffer(path: "second.rb")
731
+ @dispatcher.dispatch_ex(@editor, "ls")
732
+
733
+ lines = @editor.hit_enter_lines
734
+ # Each line should contain the buffer id and name
735
+ assert_match(/1.*\[No Name\]/, lines[0])
736
+ assert_match(/2.*"second\.rb"/, lines[1])
737
+ end
738
+
739
+ def test_equal_equal_indents_current_line
740
+ @editor.current_buffer.replace_all_lines!(["def foo", "bar", "end"])
741
+ @editor.assign_filetype(@editor.current_buffer, "ruby")
742
+ @editor.current_window.cursor_y = 1
743
+ @editor.current_window.cursor_x = 0
744
+
745
+ feed("=", "=")
746
+
747
+ assert_equal ["def foo", " bar", "end"], @editor.current_buffer.lines
748
+ end
749
+
750
+ def test_equal_j_indents_two_lines
751
+ @editor.current_buffer.replace_all_lines!(["def foo", "bar", "baz", "end"])
752
+ @editor.assign_filetype(@editor.current_buffer, "ruby")
753
+ @editor.current_window.cursor_y = 1
754
+ @editor.current_window.cursor_x = 0
755
+
756
+ feed("=", "j")
757
+
758
+ assert_equal ["def foo", " bar", " baz", "end"], @editor.current_buffer.lines
759
+ end
760
+
761
+ def test_visual_equal_indents_selection
762
+ @editor.current_buffer.replace_all_lines!(["def foo", "bar", "end"])
763
+ @editor.assign_filetype(@editor.current_buffer, "ruby")
764
+ @editor.current_window.cursor_y = 0
765
+ @editor.current_window.cursor_x = 0
766
+
767
+ feed("V", "j", "j", "=")
768
+
769
+ assert_equal ["def foo", " bar", "end"], @editor.current_buffer.lines
770
+ assert_equal :normal, @editor.mode
771
+ end
772
+
773
+ # :qa / :qall tests
774
+
775
+ def test_qa_quits_with_multiple_windows
776
+ @dispatcher.dispatch_ex(@editor, "split")
777
+ assert_equal 2, @editor.window_count
778
+
779
+ @dispatcher.dispatch_ex(@editor, "qa")
780
+ assert_equal false, @editor.running?
781
+ end
782
+
783
+ def test_qa_refuses_with_unsaved_changes
784
+ @editor.current_buffer.replace_all_lines!(["modified"])
785
+ @editor.current_buffer.instance_variable_set(:@modified, true)
786
+
787
+ @dispatcher.dispatch_ex(@editor, "qa")
788
+ assert @editor.running?
789
+ assert_match(/unsaved changes/, @editor.message)
790
+ end
791
+
792
+ def test_qa_bang_forces_quit_with_unsaved_changes
793
+ @editor.current_buffer.replace_all_lines!(["modified"])
794
+ @editor.current_buffer.instance_variable_set(:@modified, true)
795
+
796
+ @dispatcher.dispatch_ex(@editor, "qa!")
797
+ assert_equal false, @editor.running?
798
+ end
799
+
800
+ def test_wqa_writes_all_and_quits
801
+ Dir.mktmpdir do |dir|
802
+ path1 = File.join(dir, "a.txt")
803
+ path2 = File.join(dir, "b.txt")
804
+ File.write(path1, "")
805
+ File.write(path2, "")
806
+
807
+ @editor.current_buffer.replace_all_lines!(["hello"])
808
+ @editor.current_buffer.instance_variable_set(:@path, path1)
809
+ @editor.current_buffer.instance_variable_set(:@modified, true)
810
+
811
+ buf2 = @editor.add_empty_buffer(path: path2)
812
+ buf2.replace_all_lines!(["world"])
813
+ buf2.instance_variable_set(:@modified, true)
814
+
815
+ @dispatcher.dispatch_ex(@editor, "wqa")
816
+ assert_equal false, @editor.running?
817
+ assert_equal "hello", File.read(path1)
818
+ assert_equal "world", File.read(path2)
819
+ end
820
+ end
821
+
822
+ def test_shift_right_splits_when_single_window
823
+ assert_equal 1, @editor.window_count
824
+ first_win = @editor.current_window
825
+ feed(:shift_right)
826
+ assert_equal 2, @editor.window_count
827
+ # Focus should be on the new (right) window
828
+ refute_equal first_win.id, @editor.current_window.id
829
+ assert_equal :vertical, @editor.window_layout
830
+ end
831
+
832
+ def test_shift_left_splits_when_single_window
833
+ assert_equal 1, @editor.window_count
834
+ first_win = @editor.current_window
835
+ feed(:shift_left)
836
+ assert_equal 2, @editor.window_count
837
+ # Focus should be on the new (left) window
838
+ refute_equal first_win.id, @editor.current_window.id
839
+ assert_equal :vertical, @editor.window_layout
840
+ end
841
+
842
+ def test_shift_down_splits_when_single_window
843
+ assert_equal 1, @editor.window_count
844
+ first_win = @editor.current_window
845
+ feed(:shift_down)
846
+ assert_equal 2, @editor.window_count
847
+ refute_equal first_win.id, @editor.current_window.id
848
+ assert_equal :horizontal, @editor.window_layout
849
+ end
850
+
851
+ def test_shift_up_splits_when_single_window
852
+ assert_equal 1, @editor.window_count
853
+ first_win = @editor.current_window
854
+ feed(:shift_up)
855
+ assert_equal 2, @editor.window_count
856
+ refute_equal first_win.id, @editor.current_window.id
857
+ assert_equal :horizontal, @editor.window_layout
858
+ end
859
+
860
+ def test_shift_right_splits_even_with_horizontal_split
861
+ # Horizontal split exists, but no window to the right → vsplit
862
+ @editor.split_current_window(layout: :horizontal)
863
+ assert_equal 2, @editor.window_count
864
+ feed(:shift_right)
865
+ assert_equal 3, @editor.window_count
866
+ end
867
+
868
+ def test_shift_left_splits_even_with_horizontal_split
869
+ @editor.split_current_window(layout: :horizontal)
870
+ assert_equal 2, @editor.window_count
871
+ feed(:shift_left)
872
+ assert_equal 3, @editor.window_count
873
+ end
874
+
875
+ def test_shift_down_splits_even_with_vertical_split
876
+ @editor.split_current_window(layout: :vertical)
877
+ assert_equal 2, @editor.window_count
878
+ feed(:shift_down)
879
+ assert_equal 3, @editor.window_count
880
+ end
881
+
882
+ def test_shift_up_splits_even_with_vertical_split
883
+ @editor.split_current_window(layout: :vertical)
884
+ assert_equal 2, @editor.window_count
885
+ feed(:shift_up)
886
+ assert_equal 3, @editor.window_count
887
+ end
888
+
889
+ def test_shift_arrow_moves_window_focus_when_multiple_windows
890
+ # Create a vertical split so we have two windows
891
+ first_win = @editor.current_window
892
+ @editor.split_current_window(layout: :vertical)
893
+ second_win = @editor.current_window
894
+
895
+ # After split, focus is on the new (second) window
896
+ assert_equal second_win.id, @editor.current_window.id
897
+
898
+ # Shift+Left should move focus to the left window (no new split)
899
+ feed(:shift_left)
900
+ assert_equal first_win.id, @editor.current_window.id
901
+ assert_equal 2, @editor.window_count
902
+
903
+ # Shift+Right should move focus back to the right window
904
+ feed(:shift_right)
905
+ assert_equal second_win.id, @editor.current_window.id
906
+ assert_equal 2, @editor.window_count
907
+ end
908
+
909
+ def test_shift_arrow_up_down_moves_window_focus_horizontal_split
910
+ # Create a horizontal split so we have two windows
911
+ first_win = @editor.current_window
912
+ @editor.split_current_window(layout: :horizontal)
913
+ second_win = @editor.current_window
914
+
915
+ assert_equal second_win.id, @editor.current_window.id
916
+
917
+ # Shift+Up should move focus to the upper window (no new split)
918
+ feed(:shift_up)
919
+ assert_equal first_win.id, @editor.current_window.id
920
+ assert_equal 2, @editor.window_count
921
+
922
+ # Shift+Down should move focus back to the lower window
923
+ feed(:shift_down)
924
+ assert_equal second_win.id, @editor.current_window.id
925
+ assert_equal 2, @editor.window_count
926
+ end
927
+
928
+ # --- Nested layout tree tests ---
929
+
930
+ def test_vsplit_then_split_creates_nested_layout
931
+ # Start with 1 window (win1)
932
+ win1 = @editor.current_window
933
+ # vsplit → creates win2 to the right
934
+ @editor.split_current_window(layout: :vertical)
935
+ win2 = @editor.current_window
936
+ assert_equal 2, @editor.window_count
937
+
938
+ # split the right window (win2) horizontally → creates win3 below win2
939
+ @editor.split_current_window(layout: :horizontal)
940
+ win3 = @editor.current_window
941
+ assert_equal 3, @editor.window_count
942
+
943
+ # Layout tree should be: vsplit[ win1, hsplit[ win2, win3 ] ]
944
+ tree = @editor.layout_tree
945
+ assert_equal :vsplit, tree[:type]
946
+ assert_equal 2, tree[:children].length
947
+ assert_equal :window, tree[:children][0][:type]
948
+ assert_equal win1.id, tree[:children][0][:id]
949
+ assert_equal :hsplit, tree[:children][1][:type]
950
+ assert_equal 2, tree[:children][1][:children].length
951
+
952
+ # window_order should traverse leaves left-to-right, top-to-bottom
953
+ assert_equal [win1.id, win2.id, win3.id], @editor.window_order
954
+ end
955
+
956
+ def test_split_then_vsplit_creates_nested_layout
957
+ # split → creates win2 below
958
+ @editor.split_current_window(layout: :horizontal)
959
+ assert_equal 2, @editor.window_count
960
+
961
+ # vsplit the lower window → creates win3 to the right of win2
962
+ @editor.split_current_window(layout: :vertical)
963
+ assert_equal 3, @editor.window_count
964
+
965
+ # Layout tree should be: hsplit[ win1, vsplit[ win2, win3 ] ]
966
+ tree = @editor.layout_tree
967
+ assert_equal :hsplit, tree[:type]
968
+ assert_equal 2, tree[:children].length
969
+ assert_equal :window, tree[:children][0][:type]
970
+ assert_equal :vsplit, tree[:children][1][:type]
971
+ end
972
+
973
+ def test_close_window_simplifies_nested_tree
974
+ @editor.split_current_window(layout: :vertical)
975
+ @editor.split_current_window(layout: :horizontal)
976
+ win3 = @editor.current_window
977
+ assert_equal 3, @editor.window_count
978
+
979
+ # Close win3 → hsplit node should collapse, leaving vsplit[ win1, win2 ]
980
+ @editor.close_window(win3.id)
981
+ assert_equal 2, @editor.window_count
982
+
983
+ tree = @editor.layout_tree
984
+ assert_equal :vsplit, tree[:type]
985
+ assert_equal 2, tree[:children].length
986
+ assert_equal :window, tree[:children][0][:type]
987
+ assert_equal :window, tree[:children][1][:type]
988
+ end
989
+
990
+ def test_close_window_to_single_produces_single_layout
991
+ @editor.split_current_window(layout: :vertical)
992
+ win2 = @editor.current_window
993
+ assert_equal 2, @editor.window_count
994
+
995
+ @editor.close_window(win2.id)
996
+ assert_equal 1, @editor.window_count
997
+ assert_equal :single, @editor.window_layout
998
+ end
999
+
1000
+ def test_focus_window_direction_in_nested_layout
1001
+ # Create vsplit[ win1, hsplit[ win2, win3 ] ]
1002
+ win1 = @editor.current_window
1003
+ @editor.split_current_window(layout: :vertical)
1004
+ win2 = @editor.current_window
1005
+ @editor.split_current_window(layout: :horizontal)
1006
+ win3 = @editor.current_window
1007
+
1008
+ # From win3 (bottom-right), going left should reach win1
1009
+ @editor.focus_window(win3.id)
1010
+ @editor.focus_window_direction(:left)
1011
+ assert_equal win1.id, @editor.current_window_id
1012
+
1013
+ # From win1 (left), going right should reach win2 or win3
1014
+ @editor.focus_window_direction(:right)
1015
+ assert_includes [win2.id, win3.id], @editor.current_window_id
1016
+
1017
+ # From win2 (top-right), going down should reach win3
1018
+ @editor.focus_window(win2.id)
1019
+ @editor.focus_window_direction(:down)
1020
+ assert_equal win3.id, @editor.current_window_id
1021
+
1022
+ # From win3 (bottom-right), going up should reach win2
1023
+ @editor.focus_window_direction(:up)
1024
+ assert_equal win2.id, @editor.current_window_id
1025
+ end
1026
+
1027
+ def test_shift_left_does_not_split_at_edge_of_existing_vsplit
1028
+ # vsplit creates [win1, win2], focus on win2
1029
+ @editor.split_current_window(layout: :vertical)
1030
+ # Move focus to left (win1)
1031
+ feed(:shift_left)
1032
+ assert_equal 2, @editor.window_count
1033
+
1034
+ # Now we're on win1 (leftmost). Shift+Left should NOT split because
1035
+ # there are already windows on the same axis (horizontal neighbors exist).
1036
+ feed(:shift_left)
1037
+ assert_equal 2, @editor.window_count, "Should not split at edge of existing vsplit"
1038
+
1039
+ # Pressing again should still not split
1040
+ feed(:shift_left)
1041
+ assert_equal 2, @editor.window_count
1042
+ end
1043
+
1044
+ def test_shift_left_splits_bottom_window_in_nested_layout
1045
+ # Create layout: hsplit[ vsplit[win1, win2], win3 ]
1046
+ # Start with win1
1047
+ win1 = @editor.current_window
1048
+ # vsplit → vsplit[win1, win2]
1049
+ @editor.split_current_window(layout: :vertical)
1050
+ win2 = @editor.current_window
1051
+ # Focus back to win1, then split horizontally from win1
1052
+ # Actually, easier: split from win2 horizontally to get the right structure
1053
+ # Let me build it differently: start fresh
1054
+ @editor.focus_window(win1.id)
1055
+
1056
+ # From win1, hsplit → hsplit[win1, win3], but we want vsplit on top.
1057
+ # Let me just build the tree directly.
1058
+ # Better approach: vsplit first, then move up and hsplit from the vsplit pair
1059
+ # Actually: vsplit[win1, win2], then from win1 do hsplit → hsplit[vsplit[...], win3]
1060
+ # No, that's wrong. Let me think:
1061
+ # We want hsplit[ vsplit[A, B], C ]
1062
+ # Step 1: split (horizontal) → hsplit[win1, win3], focus on win3
1063
+ @editor.close_window(win2.id)
1064
+ assert_equal 1, @editor.window_count
1065
+ @editor.split_current_window(layout: :horizontal)
1066
+ win3 = @editor.current_window
1067
+ # Step 2: focus win1 (top), then vsplit → vsplit[win1, win2] inside hsplit
1068
+ @editor.focus_window(win1.id)
1069
+ @editor.split_current_window(layout: :vertical)
1070
+ win2 = @editor.current_window
1071
+ assert_equal 3, @editor.window_count
1072
+
1073
+ # Layout should be: hsplit[ vsplit[win1, win2], win3 ]
1074
+ tree = @editor.layout_tree
1075
+ assert_equal :hsplit, tree[:type]
1076
+ assert_equal :vsplit, tree[:children][0][:type]
1077
+ assert_equal :window, tree[:children][1][:type]
1078
+ assert_equal win3.id, tree[:children][1][:id]
1079
+
1080
+ # From win3 (full-width bottom), Shift+Left should SPLIT (no vsplit ancestor)
1081
+ @editor.focus_window(win3.id)
1082
+ feed(:shift_left)
1083
+ assert_equal 4, @editor.window_count, "Shift+Left from full-width bottom should vsplit it"
1084
+ end
1085
+
1086
+ def test_same_direction_split_merges_into_parent
1087
+ # hsplit[ win1, win2 ], then split win2 again horizontally
1088
+ @editor.split_current_window(layout: :horizontal)
1089
+ @editor.split_current_window(layout: :horizontal)
1090
+ assert_equal 3, @editor.window_count
1091
+
1092
+ # All three should be in a single hsplit (no nested hsplit inside hsplit)
1093
+ tree = @editor.layout_tree
1094
+ assert_equal :hsplit, tree[:type]
1095
+ assert_equal 3, tree[:children].length
1096
+ end
1097
+
1098
+ # --- search filter (g/) ---
1099
+
1100
+ def test_filter_creates_buffer_with_matching_lines
1101
+ @editor.current_buffer.replace_all_lines!(["apple", "banana", "apricot", "cherry"])
1102
+ feed("/", "a", "p", :enter) # search for "ap"
1103
+ feed("g", "/")
1104
+
1105
+ buf = @editor.current_buffer
1106
+ assert_equal :filter, buf.kind
1107
+ assert_equal ["apple", "apricot"], buf.lines
1108
+ end
1109
+
1110
+ def test_filter_enter_jumps_to_original_line_and_closes_filter
1111
+ @editor.current_buffer.replace_all_lines!(["apple", "banana", "apricot", "cherry"])
1112
+ original_buf_id = @editor.current_buffer.id
1113
+ feed("/", "a", "p", :enter)
1114
+ feed("g", "/")
1115
+
1116
+ # Move to second match line ("apricot", originally line 2)
1117
+ feed("j")
1118
+ feed(:enter)
1119
+
1120
+ assert_equal original_buf_id, @editor.current_buffer.id
1121
+ assert_equal 2, @editor.current_window.cursor_y
1122
+ end
1123
+
1124
+ def test_filter_quit_returns_to_previous_buffer
1125
+ @editor.current_buffer.replace_all_lines!(["apple", "banana", "apricot"])
1126
+ original_buf_id = @editor.current_buffer.id
1127
+ feed("/", "a", "p", :enter)
1128
+ feed("g", "/")
1129
+
1130
+ assert_equal :filter, @editor.current_buffer.kind
1131
+ feed(":", "q", :enter)
1132
+
1133
+ assert_equal original_buf_id, @editor.current_buffer.id
1134
+ end
1135
+
1136
+ def test_filter_recursive_filtering
1137
+ @editor.current_buffer.replace_all_lines!(["apple pie", "apricot jam", "apple sauce", "cherry"])
1138
+ feed("/", "a", "p", :enter)
1139
+ feed("g", "/")
1140
+
1141
+ assert_equal ["apple pie", "apricot jam", "apple sauce"], @editor.current_buffer.lines
1142
+
1143
+ # Search within filter and filter again
1144
+ feed("/", "p", "l", "e", :enter)
1145
+ feed("g", "/")
1146
+
1147
+ assert_equal ["apple pie", "apple sauce"], @editor.current_buffer.lines
1148
+
1149
+ # Enter jumps to original buffer
1150
+ feed("j") # "apple sauce" - originally line 2 of buffer
1151
+ feed(:enter)
1152
+
1153
+ assert_equal 2, @editor.current_window.cursor_y
1154
+ end
1155
+
1156
+ def test_filter_inherits_filetype
1157
+ @editor.current_buffer.replace_all_lines!(["a\tb", "c\td", "a\te"])
1158
+ @editor.current_buffer.options["filetype"] = "tsv"
1159
+ feed("/", "a", :enter)
1160
+ feed("g", "/")
1161
+
1162
+ assert_equal "tsv", @editor.current_buffer.options["filetype"]
1163
+ end
1164
+
1165
+ def test_filter_without_search_pattern_shows_error
1166
+ @editor.current_buffer.replace_all_lines!(["apple", "banana"])
1167
+ feed("g", "/")
1168
+
1169
+ assert @editor.message_error?
1170
+ end
1171
+
1172
+ def test_filter_quit_restores_cursor_position
1173
+ @editor.current_buffer.replace_all_lines!(["aaa", "bbb", "aab", "ccc", "aac"])
1174
+ original_buf_id = @editor.current_buffer.id
1175
+ feed("/", "a", "a", :enter)
1176
+ # Search moves cursor to line 0 (first match)
1177
+ feed("n")
1178
+ # Now on line 2 ("aab")
1179
+ assert_equal 2, @editor.current_window.cursor_y
1180
+ feed("g", "/")
1181
+
1182
+ assert_equal :filter, @editor.current_buffer.kind
1183
+ feed(":", "q", :enter)
1184
+
1185
+ assert_equal original_buf_id, @editor.current_buffer.id
1186
+ assert_equal 2, @editor.current_window.cursor_y
1187
+ end
1188
+
1189
+ def test_filter_ex_command
1190
+ @editor.current_buffer.replace_all_lines!(["apple", "banana", "apricot"])
1191
+ feed("/", "a", "p", :enter)
1192
+ feed(":", "f", "i", "l", "t", "e", "r", :enter)
1193
+
1194
+ assert_equal :filter, @editor.current_buffer.kind
1195
+ assert_equal ["apple", "apricot"], @editor.current_buffer.lines
1196
+ end
1197
+
1198
+ # --- dG / dgg / yG / ygg / cG / cgg ---
1199
+
1200
+ def test_dG_deletes_from_cursor_to_end
1201
+ @editor.current_buffer.replace_all_lines!(["aa", "bb", "cc", "dd", "ee"])
1202
+ @editor.current_window.cursor_y = 2
1203
+ feed("d", "G")
1204
+
1205
+ assert_equal ["aa", "bb"], @editor.current_buffer.lines
1206
+ end
1207
+
1208
+ def test_dgg_deletes_from_cursor_to_start
1209
+ @editor.current_buffer.replace_all_lines!(["aa", "bb", "cc", "dd", "ee"])
1210
+ @editor.current_window.cursor_y = 2
1211
+ feed("d", "g", "g")
1212
+
1213
+ assert_equal ["dd", "ee"], @editor.current_buffer.lines
1214
+ end
1215
+
1216
+ def test_yG_yanks_from_cursor_to_end
1217
+ @editor.current_buffer.replace_all_lines!(["aa", "bb", "cc", "dd"])
1218
+ @editor.current_window.cursor_y = 1
1219
+ feed("y", "G")
1220
+
1221
+ reg = @editor.get_register('"')&.fetch(:text, "")
1222
+ assert_includes reg, "bb"
1223
+ assert_includes reg, "dd"
1224
+ end
1225
+
1226
+ def test_ygg_yanks_from_cursor_to_start
1227
+ @editor.current_buffer.replace_all_lines!(["aa", "bb", "cc", "dd"])
1228
+ @editor.current_window.cursor_y = 2
1229
+ feed("y", "g", "g")
1230
+
1231
+ reg = @editor.get_register('"')&.fetch(:text, "")
1232
+ assert_includes reg, "aa"
1233
+ assert_includes reg, "cc"
1234
+ end
1235
+
1236
+ def test_cG_changes_from_cursor_to_end
1237
+ @editor.current_buffer.replace_all_lines!(["aa", "bb", "cc", "dd"])
1238
+ @editor.current_window.cursor_y = 2
1239
+ feed("c", "G")
1240
+
1241
+ assert_equal ["aa", "bb", ""], @editor.current_buffer.lines
1242
+ assert_equal :insert, @editor.mode
1243
+ end
347
1244
  end