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
data/lib/ruvim/editor.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module RuVim
2
4
  class Editor
3
5
  OPTION_DEFS = {
@@ -45,20 +47,36 @@ module RuVim
45
47
  "expandtab" => { default_scope: :buffer, type: :bool, default: false },
46
48
  "shiftwidth" => { default_scope: :buffer, type: :int, default: 2 },
47
49
  "softtabstop" => { default_scope: :buffer, type: :int, default: 0 },
48
- "autoindent" => { default_scope: :buffer, type: :bool, default: false },
49
- "smartindent" => { default_scope: :buffer, type: :bool, default: false },
50
+ "autoindent" => { default_scope: :buffer, type: :bool, default: true },
51
+ "smartindent" => { default_scope: :buffer, type: :bool, default: true },
50
52
  "iskeyword" => { default_scope: :buffer, type: :string, default: nil },
51
53
  "tabstop" => { default_scope: :buffer, type: :int, default: 2 },
52
- "filetype" => { default_scope: :buffer, type: :string, default: nil }
54
+ "filetype" => { default_scope: :buffer, type: :string, default: nil },
55
+ "onsavehook" => { default_scope: :buffer, type: :bool, default: true },
56
+ "grepprg" => { default_scope: :global, type: :string, default: "grep -nH" },
57
+ "grepformat" => { default_scope: :global, type: :string, default: "%f:%l:%m" }
53
58
  }.freeze
54
-
55
- attr_reader :buffers, :windows
56
- attr_accessor :current_window_id, :mode, :message, :pending_count, :alternate_buffer_id, :window_layout, :restricted_mode, :current_window_view_height_hint
59
+ SHEBANG_FILETYPE_RULES = [
60
+ [/\Aruby(?:\d+(?:\.\d+)*)?\z/, "ruby"],
61
+ [/\Apython(?:\d+(?:\.\d+)*)?\z/, "python"],
62
+ ["node", "javascript"],
63
+ ["nodejs", "javascript"],
64
+ ["deno", "javascript"],
65
+ ["bash", "sh"],
66
+ ["sh", "sh"],
67
+ ["zsh", "sh"],
68
+ ["ksh", "sh"],
69
+ ["dash", "sh"],
70
+ [/\Aperl(?:\d+(?:\.\d+)*)?\z/, "perl"]
71
+ ].freeze
72
+
73
+ attr_reader :buffers, :windows, :layout_tree
74
+ attr_accessor :current_window_id, :mode, :message, :pending_count, :alternate_buffer_id, :restricted_mode, :current_window_view_height_hint, :stdin_stream_stop_handler, :open_path_handler, :keymap_manager, :app_action_handler
57
75
 
58
76
  def initialize
59
77
  @buffers = {}
60
78
  @windows = {}
61
- @window_order = []
79
+ @layout_tree = nil
62
80
  @tabpages = []
63
81
  @current_tabpage_index = nil
64
82
  @next_tabpage_id = 1
@@ -68,7 +86,6 @@ module RuVim
68
86
  @current_window_id = nil
69
87
  @alternate_buffer_id = nil
70
88
  @mode = :normal
71
- @window_layout = :single
72
89
  @message = ""
73
90
  @message_kind = :info
74
91
  @message_deadline = nil
@@ -76,6 +93,10 @@ module RuVim
76
93
  @restricted_mode = false
77
94
  @current_window_view_height_hint = 1
78
95
  @running = true
96
+ @stdin_stream_stop_handler = nil
97
+ @open_path_handler = nil
98
+ @keymap_manager = nil
99
+ @app_action_handler = nil
79
100
  @global_options = default_global_options
80
101
  @command_line = CommandLine.new
81
102
  @last_search = nil
@@ -89,8 +110,12 @@ module RuVim
89
110
  @macros = {}
90
111
  @macro_recording = nil
91
112
  @visual_state = nil
113
+ @rich_state = nil
92
114
  @quickfix_list = { items: [], index: nil }
93
115
  @location_lists = Hash.new { |h, k| h[k] = { items: [], index: nil } }
116
+ @arglist = []
117
+ @arglist_index = 0
118
+ @hit_enter_lines = nil
94
119
  end
95
120
 
96
121
  def running?
@@ -141,6 +166,22 @@ module RuVim
141
166
  @buffers.fetch(current_window.buffer_id)
142
167
  end
143
168
 
169
+ def stdin_stream_stop_or_cancel!
170
+ handler = @stdin_stream_stop_handler
171
+ return false unless handler
172
+
173
+ handler.call
174
+ true
175
+ end
176
+
177
+ def invoke_app_action(name, **kwargs)
178
+ handler = @app_action_handler
179
+ return false unless handler
180
+
181
+ handler.call(name.to_sym, **kwargs)
182
+ true
183
+ end
184
+
144
185
  def option_def(name)
145
186
  OPTION_DEFS[name.to_s]
146
187
  end
@@ -213,7 +254,7 @@ module RuVim
213
254
  base = File.basename(p)
214
255
  return "ruby" if %w[Gemfile Rakefile Guardfile].include?(base)
215
256
 
216
- {
257
+ ext_ft = {
217
258
  ".rb" => "ruby",
218
259
  ".rake" => "ruby",
219
260
  ".ru" => "ruby",
@@ -231,8 +272,16 @@ module RuVim
231
272
  ".txt" => "text",
232
273
  ".html" => "html",
233
274
  ".css" => "css",
234
- ".sh" => "sh"
275
+ ".sh" => "sh",
276
+ ".tsv" => "tsv",
277
+ ".csv" => "csv",
278
+ ".scm" => "scheme",
279
+ ".ss" => "scheme",
280
+ ".sld" => "scheme"
235
281
  }[File.extname(base).downcase]
282
+ return ext_ft if ext_ft
283
+
284
+ detect_filetype_from_shebang(p)
236
285
  end
237
286
 
238
287
  def registers
@@ -481,6 +530,53 @@ module RuVim
481
530
  end
482
531
  end
483
532
 
533
+ def rich_state
534
+ @rich_state
535
+ end
536
+
537
+ def rich_mode?
538
+ @mode == :rich
539
+ end
540
+
541
+ def enter_rich_mode(format:, delimiter:)
542
+ @mode = :rich
543
+ @pending_count = nil
544
+ @rich_state = { format: format, delimiter: delimiter }
545
+ end
546
+
547
+ def exit_rich_mode
548
+ @rich_state = nil
549
+ enter_normal_mode
550
+ end
551
+
552
+ def hit_enter_active?
553
+ @mode == :hit_enter
554
+ end
555
+
556
+ def hit_enter_lines
557
+ @hit_enter_lines
558
+ end
559
+
560
+ def enter_hit_enter_mode(lines)
561
+ @mode = :hit_enter
562
+ @hit_enter_lines = Array(lines)
563
+ @pending_count = nil
564
+ end
565
+
566
+ def exit_hit_enter_mode
567
+ @hit_enter_lines = nil
568
+ enter_normal_mode
569
+ end
570
+
571
+ def echo_multiline(lines)
572
+ lines = Array(lines)
573
+ if lines.length <= 1
574
+ echo(lines.first.to_s)
575
+ else
576
+ enter_hit_enter_mode(lines)
577
+ end
578
+ end
579
+
484
580
  def add_empty_buffer(path: nil)
485
581
  id = next_buffer_id
486
582
  buffer = Buffer.new(id:, path:)
@@ -492,7 +588,7 @@ module RuVim
492
588
  def add_virtual_buffer(kind:, name:, lines:, filetype: nil, readonly: true, modifiable: false)
493
589
  id = next_buffer_id
494
590
  buffer = Buffer.new(id:, lines:, kind:, name:, readonly:, modifiable:)
495
- buffer.options["filetype"] = filetype if filetype
591
+ assign_filetype(buffer, filetype) if filetype
496
592
  @buffers[id] = buffer
497
593
  buffer
498
594
  end
@@ -509,7 +605,17 @@ module RuVim
509
605
  id = next_window_id
510
606
  window = Window.new(id:, buffer_id:)
511
607
  @windows[id] = window
512
- @window_order << id
608
+ leaf = { type: :window, id: id }
609
+ if @layout_tree.nil?
610
+ @layout_tree = leaf
611
+ else
612
+ # Append as sibling — used for initial bootstrap only
613
+ if @layout_tree[:type] == :window
614
+ @layout_tree = { type: :hsplit, children: [@layout_tree, leaf] }
615
+ else
616
+ @layout_tree[:children] << leaf
617
+ end
618
+ end
513
619
  @current_window_id ||= id
514
620
  ensure_initial_tabpage!
515
621
  save_current_tabpage_state! unless @suspend_tab_autosave
@@ -522,15 +628,17 @@ module RuVim
522
628
  id = next_window_id
523
629
  win = Window.new(id:, buffer_id: src.buffer_id)
524
630
  @windows[id] = win
525
- src_idx = @window_order.index(src.id) || (@window_order.length - 1)
526
- insert_idx = (place.to_sym == :before ? src_idx : src_idx + 1)
527
- @window_order.insert(insert_idx, win.id)
528
631
  ensure_initial_tabpage!
529
632
  win.cursor_x = src.cursor_x
530
633
  win.cursor_y = src.cursor_y
531
634
  win.row_offset = src.row_offset
532
635
  win.col_offset = src.col_offset
533
- @window_layout = layout.to_sym
636
+
637
+ split_type = (layout.to_sym == :vertical ? :vsplit : :hsplit)
638
+ new_leaf = { type: :window, id: win.id }
639
+
640
+ @layout_tree = tree_split_leaf(@layout_tree, src.id, split_type, new_leaf, place.to_sym)
641
+
534
642
  @current_window_id = win.id
535
643
  save_current_tabpage_state! unless @suspend_tab_autosave
536
644
  win
@@ -541,18 +649,21 @@ module RuVim
541
649
  end
542
650
 
543
651
  def close_window(id)
544
- return nil if @window_order.empty?
545
- return nil if @window_order.length <= 1
546
- return nil unless @window_order.include?(id)
652
+ leaves = tree_leaves(@layout_tree)
653
+ return nil if leaves.empty?
654
+ return nil if leaves.length <= 1
655
+ return nil unless leaves.include?(id)
547
656
 
548
657
  save_current_tabpage_state! unless @suspend_tab_autosave
549
- idx = @window_order.index(id) || 0
658
+ idx = leaves.index(id) || 0
550
659
  @windows.delete(id)
551
- @window_order.delete(id)
552
660
  @location_lists.delete(id)
553
- @current_window_id = @window_order[[idx, @window_order.length - 1].min] if @current_window_id == id
554
- @current_window_id ||= @window_order.first
555
- @window_layout = :single if @window_order.length <= 1
661
+
662
+ @layout_tree = tree_remove(@layout_tree, id)
663
+
664
+ new_leaves = tree_leaves(@layout_tree)
665
+ @current_window_id = new_leaves[[idx, new_leaves.length - 1].min] if @current_window_id == id
666
+ @current_window_id ||= new_leaves.first
556
667
  save_current_tabpage_state! unless @suspend_tab_autosave
557
668
  current_window
558
669
  end
@@ -563,7 +674,8 @@ module RuVim
563
674
 
564
675
  save_current_tabpage_state!
565
676
  removed = @tabpages.delete_at(@current_tabpage_index)
566
- Array(removed && removed[:window_order]).each do |wid|
677
+ removed_tree = removed && removed[:layout_tree]
678
+ tree_leaves(removed_tree).each do |wid|
567
679
  @windows.delete(wid)
568
680
  @location_lists.delete(wid)
569
681
  end
@@ -581,42 +693,55 @@ module RuVim
581
693
  end
582
694
 
583
695
  def focus_next_window
584
- return current_window if @window_order.length <= 1
696
+ order = window_order
697
+ return current_window if order.length <= 1
585
698
 
586
- idx = @window_order.index(@current_window_id) || 0
587
- focus_window(@window_order[(idx + 1) % @window_order.length])
699
+ idx = order.index(@current_window_id) || 0
700
+ focus_window(order[(idx + 1) % order.length])
588
701
  end
589
702
 
590
703
  def focus_prev_window
591
- return current_window if @window_order.length <= 1
704
+ order = window_order
705
+ return current_window if order.length <= 1
592
706
 
593
- idx = @window_order.index(@current_window_id) || 0
594
- focus_window(@window_order[(idx - 1) % @window_order.length])
707
+ idx = order.index(@current_window_id) || 0
708
+ focus_window(order[(idx - 1) % order.length])
595
709
  end
596
710
 
597
711
  def focus_window_direction(dir)
598
- return current_window if @window_order.length <= 1
599
-
600
- case @window_layout
601
- when :vertical
602
- if dir == :left
603
- focus_prev_window
604
- elsif dir == :right
605
- focus_next_window
606
- else
607
- current_window
712
+ leaves = tree_leaves(@layout_tree)
713
+ return current_window if leaves.length <= 1
714
+
715
+ rects = tree_compute_rects(@layout_tree, top: 0.0, left: 0.0, height: 1.0, width: 1.0)
716
+ cur = rects[@current_window_id]
717
+ return current_window unless cur
718
+
719
+ best_id = nil
720
+ best_dist = Float::INFINITY
721
+ cur_cx = cur[:left] + cur[:width] / 2.0
722
+ cur_cy = cur[:top] + cur[:height] / 2.0
723
+
724
+ rects.each do |wid, r|
725
+ next if wid == @current_window_id
726
+ rcx = r[:left] + r[:width] / 2.0
727
+ rcy = r[:top] + r[:height] / 2.0
728
+
729
+ in_direction = case dir
730
+ when :left then rcx < cur_cx
731
+ when :right then rcx > cur_cx
732
+ when :up then rcy < cur_cy
733
+ when :down then rcy > cur_cy
734
+ end
735
+ next unless in_direction
736
+
737
+ dist = (rcx - cur_cx).abs + (rcy - cur_cy).abs
738
+ if dist < best_dist
739
+ best_dist = dist
740
+ best_id = wid
608
741
  end
609
- when :horizontal
610
- if dir == :up
611
- focus_prev_window
612
- elsif dir == :down
613
- focus_next_window
614
- else
615
- current_window
616
- end
617
- else
618
- focus_next_window
619
742
  end
743
+
744
+ best_id ? focus_window(best_id) : current_window
620
745
  end
621
746
 
622
747
  def switch_to_buffer(buffer_id)
@@ -634,9 +759,16 @@ module RuVim
634
759
  end
635
760
 
636
761
  def open_path(path)
762
+ handler = @open_path_handler
763
+ return handler.call(path) if handler
764
+
765
+ open_path_sync(path)
766
+ end
767
+
768
+ def open_path_sync(path)
637
769
  buffer = add_buffer_from_file(path)
638
770
  switch_to_buffer(buffer.id)
639
- echo(path && File.exist?(path) ? "\"#{path}\" #{buffer.line_count}L" : "\"#{path}\" [New File]")
771
+ echo("[New File]") unless path && File.exist?(path)
640
772
  buffer
641
773
  end
642
774
 
@@ -672,7 +804,7 @@ module RuVim
672
804
  current_buffer.replace_all_lines!(intro_lines)
673
805
  current_buffer.configure_special!(kind: :intro, name: "[Intro]", readonly: true, modifiable: false)
674
806
  current_buffer.modified = false
675
- current_buffer.options["filetype"] = "help"
807
+ assign_filetype(current_buffer, "help")
676
808
  current_window.cursor_x = 0
677
809
  current_window.cursor_y = 0
678
810
  current_window.row_offset = 0
@@ -685,7 +817,7 @@ module RuVim
685
817
  return false unless current_buffer.intro_buffer?
686
818
 
687
819
  current_buffer.become_normal_empty_buffer!
688
- current_buffer.options["filetype"] = nil
820
+ assign_filetype(current_buffer, nil)
689
821
  current_window.cursor_x = 0
690
822
  current_window.cursor_y = 0
691
823
  current_window.row_offset = 0
@@ -745,7 +877,16 @@ module RuVim
745
877
  end
746
878
 
747
879
  def window_order
748
- @window_order
880
+ tree_leaves(@layout_tree)
881
+ end
882
+
883
+ def window_layout
884
+ return :single if @layout_tree.nil? || @layout_tree[:type] == :window
885
+ case @layout_tree[:type]
886
+ when :vsplit then :vertical
887
+ when :hsplit then :horizontal
888
+ else :single
889
+ end
749
890
  end
750
891
 
751
892
  def tabpages
@@ -764,8 +905,12 @@ module RuVim
764
905
  @tabpages.length
765
906
  end
766
907
 
908
+ def tabpage_windows(tab)
909
+ tree_leaves(tab[:layout_tree])
910
+ end
911
+
767
912
  def window_count
768
- @window_order.length
913
+ tree_leaves(@layout_tree).length
769
914
  end
770
915
 
771
916
  def quickfix_items
@@ -778,7 +923,7 @@ module RuVim
778
923
 
779
924
  def set_quickfix_list(items)
780
925
  ary = Array(items).map { |it| normalize_location(it)&.merge(text: (it[:text] || it["text"]).to_s) }.compact
781
- @quickfix_list = { items: ary, index: ary.empty? ? nil : 0 }
926
+ @quickfix_list = { items: ary, index: nil }
782
927
  @quickfix_list
783
928
  end
784
929
 
@@ -791,7 +936,12 @@ module RuVim
791
936
  items = @quickfix_list[:items]
792
937
  return nil if items.empty?
793
938
 
794
- @quickfix_list[:index] = ((@quickfix_list[:index] || 0) + step.to_i) % items.length
939
+ cur = @quickfix_list[:index]
940
+ @quickfix_list[:index] = if cur.nil?
941
+ step.to_i > 0 ? 0 : items.length - 1
942
+ else
943
+ (cur + step.to_i) % items.length
944
+ end
795
945
  current_quickfix_item
796
946
  end
797
947
 
@@ -814,7 +964,7 @@ module RuVim
814
964
 
815
965
  def set_location_list(items, window_id: current_window_id)
816
966
  ary = Array(items).map { |it| normalize_location(it)&.merge(text: (it[:text] || it["text"]).to_s) }.compact
817
- @location_lists[window_id] = { items: ary, index: ary.empty? ? nil : 0 }
967
+ @location_lists[window_id] = { items: ary, index: nil }
818
968
  @location_lists[window_id]
819
969
  end
820
970
 
@@ -829,7 +979,12 @@ module RuVim
829
979
  items = list[:items]
830
980
  return nil if items.empty?
831
981
 
832
- list[:index] = ((list[:index] || 0) + step.to_i) % items.length
982
+ cur = list[:index]
983
+ list[:index] = if cur.nil?
984
+ step.to_i > 0 ? 0 : items.length - 1
985
+ else
986
+ (cur + step.to_i) % items.length
987
+ end
833
988
  current_location_list_item(window_id)
834
989
  end
835
990
 
@@ -843,9 +998,22 @@ module RuVim
843
998
  current_location_list_item(window_id)
844
999
  end
845
1000
 
1001
+ # Check if the path from root to the current window passes through
1002
+ # a split node matching the direction's axis. Used by focus_or_split
1003
+ # to decide whether to split or stay put at edges.
1004
+ # left/right → check for :vsplit ancestor
1005
+ # up/down → check for :hsplit ancestor
1006
+ def has_split_ancestor_on_axis?(dir)
1007
+ target_type = case dir
1008
+ when :left, :right then :vsplit
1009
+ when :up, :down then :hsplit
1010
+ end
1011
+ tree_path_has_split_type?(@layout_tree, @current_window_id, target_type)
1012
+ end
1013
+
846
1014
  def find_window_ids_by_buffer_kind(kind)
847
1015
  sym = kind.to_sym
848
- @window_order.select do |wid|
1016
+ window_order.select do |wid|
849
1017
  win = @windows[wid]
850
1018
  buf = win && @buffers[win.buffer_id]
851
1019
  buf && buf.kind == sym
@@ -857,9 +1025,8 @@ module RuVim
857
1025
  save_current_tabpage_state!
858
1026
 
859
1027
  with_tab_autosave_suspended do
860
- @window_order = []
1028
+ @layout_tree = nil
861
1029
  @current_window_id = nil
862
- @window_layout = :single
863
1030
 
864
1031
  buffer = path ? add_buffer_from_file(path) : add_empty_buffer
865
1032
  add_window(buffer_id: buffer.id)
@@ -886,6 +1053,7 @@ module RuVim
886
1053
  @mode = :normal
887
1054
  @pending_count = nil
888
1055
  clear_visual
1056
+ @rich_state = nil
889
1057
  end
890
1058
 
891
1059
  def enter_insert_mode
@@ -901,7 +1069,16 @@ module RuVim
901
1069
 
902
1070
  def cancel_command_line
903
1071
  @command_line.clear
904
- enter_normal_mode
1072
+ leave_command_line
1073
+ end
1074
+
1075
+ def leave_command_line
1076
+ if @rich_state
1077
+ @mode = :rich
1078
+ @pending_count = nil
1079
+ else
1080
+ enter_normal_mode
1081
+ end
905
1082
  end
906
1083
 
907
1084
  def echo(msg)
@@ -974,8 +1151,69 @@ module RuVim
974
1151
  command_line_active? || message_error?
975
1152
  end
976
1153
 
1154
+ def assign_filetype(buffer, ft)
1155
+ buffer.options["filetype"] = ft
1156
+ buffer.lang_module = resolve_lang_module(ft)
1157
+ end
1158
+
977
1159
  private
978
1160
 
1161
+ def resolve_lang_module(ft)
1162
+ case ft
1163
+ when "ruby" then Lang::Ruby
1164
+ else Lang::Base
1165
+ end
1166
+ end
1167
+
1168
+ def detect_filetype_from_shebang(path)
1169
+ line = read_first_line(path)
1170
+ return nil unless line.start_with?("#!")
1171
+
1172
+ cmd = shebang_command_name(line)
1173
+ return nil if cmd.nil? || cmd.empty?
1174
+
1175
+ rule = SHEBANG_FILETYPE_RULES.find do |matcher, _filetype|
1176
+ matcher.is_a?(Regexp) ? matcher.match?(cmd) : matcher.to_s == cmd
1177
+ end
1178
+ rule && rule[1]
1179
+ rescue StandardError
1180
+ nil
1181
+ end
1182
+
1183
+ def read_first_line(path)
1184
+ return "" unless path && !path.empty?
1185
+ return "" unless File.file?(path)
1186
+
1187
+ File.open(path, "rb") do |f|
1188
+ (f.gets || "").to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
1189
+ end
1190
+ rescue StandardError
1191
+ ""
1192
+ end
1193
+
1194
+ def shebang_command_name(line)
1195
+ src = line.to_s.sub(/\A#!/, "").strip
1196
+ return nil if src.empty?
1197
+
1198
+ tokens = src.split(/\s+/)
1199
+ return nil if tokens.empty?
1200
+
1201
+ prog = tokens[0].to_s
1202
+ if File.basename(prog) == "env"
1203
+ i = 1
1204
+ while i < tokens.length && tokens[i].start_with?("-")
1205
+ if tokens[i] == "-S"
1206
+ i += 1
1207
+ break
1208
+ end
1209
+ i += 1
1210
+ end
1211
+ prog = tokens[i].to_s
1212
+ end
1213
+
1214
+ File.basename(prog.to_s)
1215
+ end
1216
+
979
1217
  def default_global_options
980
1218
  OPTION_DEFS.each_with_object({}) { |(k, v), h| h[k] = v[:default] }
981
1219
  end
@@ -1033,7 +1271,7 @@ module RuVim
1033
1271
 
1034
1272
  def assign_detected_filetype(buffer)
1035
1273
  ft = detect_filetype(buffer.path)
1036
- buffer.options["filetype"] = ft if ft && !ft.empty?
1274
+ assign_filetype(buffer, ft) if ft && !ft.empty?
1037
1275
  buffer
1038
1276
  end
1039
1277
 
@@ -1070,6 +1308,51 @@ module RuVim
1070
1308
  current_location
1071
1309
  end
1072
1310
 
1311
+ def arglist
1312
+ @arglist.dup
1313
+ end
1314
+
1315
+ def arglist_index
1316
+ @arglist_index
1317
+ end
1318
+
1319
+ def set_arglist(paths)
1320
+ @arglist = Array(paths).dup
1321
+ @arglist_index = 0
1322
+ end
1323
+
1324
+ def arglist_current
1325
+ @arglist[@arglist_index] if @arglist_index < @arglist.length
1326
+ end
1327
+
1328
+ def arglist_next(count = 1)
1329
+ new_index = @arglist_index + count
1330
+ if new_index >= @arglist.length
1331
+ raise RuVim::CommandError, "Already at last argument"
1332
+ end
1333
+ @arglist_index = new_index
1334
+ @arglist[@arglist_index]
1335
+ end
1336
+
1337
+ def arglist_prev(count = 1)
1338
+ new_index = @arglist_index - count
1339
+ if new_index < 0
1340
+ raise RuVim::CommandError, "Already at first argument"
1341
+ end
1342
+ @arglist_index = new_index
1343
+ @arglist[@arglist_index]
1344
+ end
1345
+
1346
+ def arglist_first
1347
+ @arglist_index = 0
1348
+ @arglist[@arglist_index] if @arglist.length > 0
1349
+ end
1350
+
1351
+ def arglist_last
1352
+ @arglist_index = [@arglist.length - 1, 0].max
1353
+ @arglist[@arglist_index] if @arglist.length > 0
1354
+ end
1355
+
1073
1356
  private
1074
1357
 
1075
1358
  def first_nonblank_col(buffer, row)
@@ -1136,7 +1419,7 @@ module RuVim
1136
1419
 
1137
1420
  def ensure_initial_tabpage!
1138
1421
  return unless @tabpages.empty?
1139
- return if @window_order.empty?
1422
+ return if @layout_tree.nil?
1140
1423
 
1141
1424
  @tabpages << new_tabpage_snapshot
1142
1425
  @current_tabpage_index = 0
@@ -1150,18 +1433,16 @@ module RuVim
1150
1433
  end
1151
1434
 
1152
1435
  def load_tabpage_state!(tab)
1153
- @window_order = tab[:window_order].dup
1436
+ @layout_tree = tree_deep_dup(tab[:layout_tree])
1154
1437
  @current_window_id = tab[:current_window_id]
1155
- @window_layout = tab[:window_layout]
1156
1438
  current_window
1157
1439
  end
1158
1440
 
1159
1441
  def new_tabpage_snapshot(id: nil)
1160
1442
  {
1161
1443
  id: id || next_tabpage_id,
1162
- window_order: @window_order.dup,
1163
- current_window_id: @current_window_id,
1164
- window_layout: @window_layout
1444
+ layout_tree: tree_deep_dup(@layout_tree),
1445
+ current_window_id: @current_window_id
1165
1446
  }
1166
1447
  end
1167
1448
 
@@ -1190,5 +1471,108 @@ module RuVim
1190
1471
  ensure
1191
1472
  @suspend_tab_autosave = prev
1192
1473
  end
1474
+
1475
+ # --- Layout tree helpers ---
1476
+
1477
+ def tree_leaves(node)
1478
+ return [] if node.nil?
1479
+ return [node[:id]] if node[:type] == :window
1480
+
1481
+ node[:children].flat_map { |c| tree_leaves(c) }
1482
+ end
1483
+
1484
+ def tree_deep_dup(node)
1485
+ return nil if node.nil?
1486
+ return { type: :window, id: node[:id] } if node[:type] == :window
1487
+
1488
+ { type: node[:type], children: node[:children].map { |c| tree_deep_dup(c) } }
1489
+ end
1490
+
1491
+ # Split a leaf node into a new split node. If the parent is already the same
1492
+ # split type, merge into the parent instead of nesting.
1493
+ def tree_split_leaf(node, target_id, split_type, new_leaf, place)
1494
+ if node[:type] == :window
1495
+ if node[:id] == target_id
1496
+ children = place == :before ? [new_leaf, node] : [node, new_leaf]
1497
+ return { type: split_type, children: children }
1498
+ end
1499
+ return node
1500
+ end
1501
+
1502
+ new_children = node[:children].flat_map do |child|
1503
+ result = tree_split_leaf(child, target_id, split_type, new_leaf, place)
1504
+ # If the child was replaced with the same split type as this node, merge
1505
+ if result[:type] == node[:type] && result != child
1506
+ result[:children]
1507
+ else
1508
+ [result]
1509
+ end
1510
+ end
1511
+
1512
+ { type: node[:type], children: new_children }
1513
+ end
1514
+
1515
+ def tree_remove(node, target_id)
1516
+ return nil if node.nil?
1517
+ return nil if node[:type] == :window && node[:id] == target_id
1518
+ return node if node[:type] == :window
1519
+
1520
+ new_children = node[:children].filter_map { |c| tree_remove(c, target_id) }
1521
+ return nil if new_children.empty?
1522
+ return new_children.first if new_children.length == 1
1523
+
1524
+ { type: node[:type], children: new_children }
1525
+ end
1526
+
1527
+ # Check if the path from root to the target window passes through
1528
+ # a split node of the given type. Returns true if any ancestor of the
1529
+ # target window in the tree has type == split_type.
1530
+ def tree_path_has_split_type?(node, target_id, split_type)
1531
+ return false if node.nil?
1532
+ return false if node[:type] == :window
1533
+ # Check if target is in any child subtree
1534
+ node[:children].each do |child|
1535
+ if tree_subtree_contains?(child, target_id)
1536
+ return true if node[:type] == split_type
1537
+ return tree_path_has_split_type?(child, target_id, split_type)
1538
+ end
1539
+ end
1540
+ false
1541
+ end
1542
+
1543
+ def tree_subtree_contains?(node, target_id)
1544
+ return false if node.nil?
1545
+ return node[:id] == target_id if node[:type] == :window
1546
+ node[:children].any? { |c| tree_subtree_contains?(c, target_id) }
1547
+ end
1548
+
1549
+ # Compute normalized rects (0.0–1.0 coordinate space) for direction finding.
1550
+ # Separators are ignored here — this is purely for adjacency/direction logic.
1551
+ def tree_compute_rects(node, top:, left:, height:, width:)
1552
+ return {} if node.nil?
1553
+
1554
+ if node[:type] == :window
1555
+ return { node[:id] => { top: top, left: left, height: height, width: width } }
1556
+ end
1557
+
1558
+ children = node[:children]
1559
+ n = children.length
1560
+ rects = {}
1561
+
1562
+ case node[:type]
1563
+ when :vsplit
1564
+ w_each = width / n.to_f
1565
+ children.each_with_index do |child, i|
1566
+ rects.merge!(tree_compute_rects(child, top: top, left: left + i * w_each, height: height, width: w_each))
1567
+ end
1568
+ when :hsplit
1569
+ h_each = height / n.to_f
1570
+ children.each_with_index do |child, i|
1571
+ rects.merge!(tree_compute_rects(child, top: top + i * h_each, left: left, height: h_each, width: width))
1572
+ end
1573
+ end
1574
+
1575
+ rects
1576
+ end
1193
1577
  end
1194
1578
  end