ruvim 0.3.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +18 -6
  3. data/README.md +15 -1
  4. data/docs/binding.md +16 -0
  5. data/docs/command.md +78 -4
  6. data/docs/config.md +10 -2
  7. data/docs/spec.md +60 -9
  8. data/docs/tutorial.md +24 -0
  9. data/docs/vim_diff.md +18 -8
  10. data/lib/ruvim/app.rb +290 -8
  11. data/lib/ruvim/buffer.rb +14 -2
  12. data/lib/ruvim/cli.rb +6 -0
  13. data/lib/ruvim/editor.rb +12 -1
  14. data/lib/ruvim/file_watcher.rb +243 -0
  15. data/lib/ruvim/git/blame.rb +245 -0
  16. data/lib/ruvim/git/branch.rb +97 -0
  17. data/lib/ruvim/git/commit.rb +102 -0
  18. data/lib/ruvim/git/diff.rb +129 -0
  19. data/lib/ruvim/git/handler.rb +84 -0
  20. data/lib/ruvim/git/log.rb +41 -0
  21. data/lib/ruvim/git/status.rb +103 -0
  22. data/lib/ruvim/global_commands.rb +176 -42
  23. data/lib/ruvim/highlighter.rb +3 -1
  24. data/lib/ruvim/input.rb +1 -0
  25. data/lib/ruvim/lang/diff.rb +41 -0
  26. data/lib/ruvim/lang/json.rb +34 -0
  27. data/lib/ruvim/rich_view/json_renderer.rb +131 -0
  28. data/lib/ruvim/rich_view/jsonl_renderer.rb +57 -0
  29. data/lib/ruvim/rich_view.rb +16 -0
  30. data/lib/ruvim/screen.rb +9 -12
  31. data/lib/ruvim/version.rb +1 -1
  32. data/lib/ruvim.rb +10 -0
  33. data/test/app_completion_test.rb +25 -0
  34. data/test/app_scenario_test.rb +169 -0
  35. data/test/cli_test.rb +14 -0
  36. data/test/clipboard_test.rb +67 -0
  37. data/test/command_line_test.rb +118 -0
  38. data/test/config_dsl_test.rb +87 -0
  39. data/test/display_width_test.rb +41 -0
  40. data/test/file_watcher_test.rb +197 -0
  41. data/test/follow_test.rb +199 -0
  42. data/test/git_blame_test.rb +713 -0
  43. data/test/highlighter_test.rb +44 -0
  44. data/test/indent_test.rb +86 -0
  45. data/test/rich_view_test.rb +256 -0
  46. data/test/search_option_test.rb +19 -0
  47. data/test/test_helper.rb +9 -0
  48. metadata +17 -1
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuVim
4
+ module Lang
5
+ module Diff
6
+ ADD_COLOR = "\e[32m" # green
7
+ DELETE_COLOR = "\e[31m" # red
8
+ HUNK_COLOR = "\e[36m" # cyan
9
+ HEADER_COLOR = "\e[1m" # bold
10
+ META_COLOR = "\e[33m" # yellow
11
+
12
+ module_function
13
+
14
+ def color_columns(text)
15
+ cols = {}
16
+ color = line_color(text)
17
+ return cols unless color
18
+
19
+ text.length.times { |i| cols[i] = color }
20
+ cols
21
+ end
22
+
23
+ def line_color(text)
24
+ return nil if text.empty?
25
+
26
+ case text
27
+ when /\A@@/
28
+ HUNK_COLOR
29
+ when /\Adiff /
30
+ HEADER_COLOR
31
+ when /\A\+/
32
+ ADD_COLOR
33
+ when /\A-/
34
+ DELETE_COLOR
35
+ when /\Aindex /, /\Anew file/, /\Adeleted file/, /\Arename/, /\Asimilarity/
36
+ META_COLOR
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -5,6 +5,40 @@ module RuVim
5
5
  module Json
6
6
  module_function
7
7
 
8
+ INDENT_OPEN_RE = /[\[{]\s*$/
9
+ INDENT_CLOSE_RE = /\A\s*[\]}]/
10
+
11
+ DEDENT_TRIGGERS = {
12
+ "}" => /\A(\s*)\}/,
13
+ "]" => /\A(\s*)\]/
14
+ }.freeze
15
+
16
+ def calculate_indent(lines, target_row, shiftwidth)
17
+ depth = 0
18
+ (0...target_row).each do |row|
19
+ line = lines[row].to_s
20
+ line.each_char do |ch|
21
+ case ch
22
+ when "{", "[" then depth += 1
23
+ when "}", "]" then depth -= 1
24
+ end
25
+ end
26
+ end
27
+
28
+ target_line = lines[target_row].to_s.lstrip
29
+ depth -= 1 if target_line.match?(INDENT_CLOSE_RE)
30
+ depth = 0 if depth < 0
31
+ depth * shiftwidth
32
+ end
33
+
34
+ def indent_trigger?(line)
35
+ line.to_s.rstrip.match?(INDENT_OPEN_RE)
36
+ end
37
+
38
+ def dedent_trigger(char)
39
+ DEDENT_TRIGGERS[char]
40
+ end
41
+
8
42
  def color_columns(text)
9
43
  cols = {}
10
44
  Highlighter.apply_regex(cols, text, /"(?:\\.|[^"\\])*"\s*(?=:)/, "\e[36m")
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RuVim
6
+ module RichView
7
+ module JsonRenderer
8
+ module_function
9
+
10
+ # Signal that this renderer creates a virtual buffer
11
+ # instead of entering rich mode on the same buffer.
12
+ def open_view!(editor)
13
+ buffer = editor.current_buffer
14
+ window = editor.current_window
15
+ text = buffer.lines.join("\n")
16
+
17
+ begin
18
+ parsed = JSON.parse(text)
19
+ rescue JSON::ParserError => e
20
+ editor.echo_error("JSON parse error: #{e.message}")
21
+ return
22
+ end
23
+
24
+ # Compute cursor's significant char offset before formatting
25
+ cursor_offset = char_offset_for(buffer.lines, window.cursor_y, window.cursor_x)
26
+ sig_count = significant_char_count(text, cursor_offset + 1)
27
+
28
+ formatted = JSON.pretty_generate(parsed)
29
+ lines = formatted.lines(chomp: true)
30
+
31
+ target_line = line_for_significant_count(formatted, sig_count)
32
+
33
+ buf = editor.add_virtual_buffer(
34
+ kind: :json_formatted,
35
+ name: "[JSON Formatted]",
36
+ lines: lines,
37
+ filetype: "json",
38
+ readonly: true,
39
+ modifiable: false
40
+ )
41
+ editor.switch_to_buffer(buf.id)
42
+ RichView.bind_close_keys(editor, buf.id)
43
+ window.cursor_y = [target_line, lines.length - 1].min
44
+ window.cursor_x = 0
45
+ editor.echo("[JSON Formatted] #{lines.length} lines")
46
+ end
47
+
48
+ # Count significant (non-whitespace-outside-strings) characters
49
+ # in text[0...byte_offset].
50
+ def significant_char_count(text, byte_offset)
51
+ in_string = false
52
+ escape = false
53
+ count = 0
54
+ text.each_char.with_index do |ch, i|
55
+ break if i >= byte_offset
56
+ if in_string
57
+ if escape
58
+ escape = false
59
+ elsif ch == "\\"
60
+ escape = true
61
+ elsif ch == '"'
62
+ in_string = false
63
+ end
64
+ count += 1
65
+ else
66
+ case ch
67
+ when '"'
68
+ in_string = true
69
+ count += 1
70
+ when " ", "\n", "\r", "\t"
71
+ # skip whitespace outside strings
72
+ else
73
+ count += 1
74
+ end
75
+ end
76
+ end
77
+ count
78
+ end
79
+
80
+ # Find the line number in text where the N-th significant character falls.
81
+ def line_for_significant_count(text, target_count)
82
+ return 0 if target_count <= 0
83
+
84
+ in_string = false
85
+ escape = false
86
+ count = 0
87
+ line = 0
88
+ text.each_char do |ch|
89
+ if ch == "\n" && !in_string
90
+ line += 1
91
+ next
92
+ end
93
+ if in_string
94
+ if escape
95
+ escape = false
96
+ elsif ch == "\\"
97
+ escape = true
98
+ elsif ch == '"'
99
+ in_string = false
100
+ end
101
+ count += 1
102
+ else
103
+ case ch
104
+ when '"'
105
+ in_string = true
106
+ count += 1
107
+ when " ", "\r", "\t"
108
+ # skip
109
+ else
110
+ count += 1
111
+ end
112
+ end
113
+ return line if count >= target_count
114
+ end
115
+ line
116
+ end
117
+
118
+ # Compute character offset in joined text from cursor row/col.
119
+ def char_offset_for(lines, row, col)
120
+ offset = 0
121
+ lines.each_with_index do |line, i|
122
+ if i == row
123
+ return offset + [col, line.length].min
124
+ end
125
+ offset += line.length + 1 # +1 for "\n"
126
+ end
127
+ offset
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RuVim
6
+ module RichView
7
+ module JsonlRenderer
8
+ module_function
9
+
10
+ SEPARATOR = "---"
11
+
12
+ def open_view!(editor)
13
+ buffer = editor.current_buffer
14
+ window = editor.current_window
15
+ cursor_row = window.cursor_y
16
+
17
+ output_lines = []
18
+ # Map from source line index to starting line in output
19
+ line_map = {}
20
+
21
+ buffer.lines.each_with_index do |raw_line, idx|
22
+ line = raw_line.to_s.strip
23
+ next if line.empty?
24
+
25
+ output_lines << SEPARATOR unless output_lines.empty?
26
+ line_map[idx] = output_lines.length
27
+
28
+ begin
29
+ parsed = JSON.parse(line)
30
+ formatted = JSON.pretty_generate(parsed)
31
+ formatted.each_line(chomp: true) { |l| output_lines << l }
32
+ rescue JSON::ParserError
33
+ output_lines << "// PARSE ERROR: #{raw_line}"
34
+ end
35
+ end
36
+
37
+ output_lines << "" if output_lines.empty?
38
+
39
+ target_line = line_map[cursor_row] || 0
40
+
41
+ buf = editor.add_virtual_buffer(
42
+ kind: :jsonl_formatted,
43
+ name: "[JSONL Formatted]",
44
+ lines: output_lines,
45
+ filetype: "json",
46
+ readonly: true,
47
+ modifiable: false
48
+ )
49
+ editor.switch_to_buffer(buf.id)
50
+ RichView.bind_close_keys(editor, buf.id)
51
+ window.cursor_y = [target_line, output_lines.length - 1].min
52
+ window.cursor_x = 0
53
+ editor.echo("[JSONL Formatted] #{output_lines.length} lines")
54
+ end
55
+ end
56
+ end
57
+ end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require_relative "rich_view/table_renderer"
4
4
  require_relative "rich_view/markdown_renderer"
5
+ require_relative "rich_view/json_renderer"
6
+ require_relative "rich_view/jsonl_renderer"
5
7
 
6
8
  module RuVim
7
9
  module RichView
@@ -46,6 +48,11 @@ module RuVim
46
48
  renderer = @renderers[format]
47
49
  raise RuVim::CommandError, "No renderer for format: #{format}" unless renderer
48
50
 
51
+ if renderer.respond_to?(:open_view!)
52
+ renderer.open_view!(editor)
53
+ return
54
+ end
55
+
49
56
  delimiter = renderer.delimiter_for(format)
50
57
  editor.enter_rich_mode(format: format, delimiter: delimiter)
51
58
  editor.echo("[Rich: #{format}]")
@@ -89,5 +96,14 @@ module RuVim
89
96
 
90
97
  editor.exit_rich_mode
91
98
  end
99
+
100
+ # Bind Esc and C-c to close a virtual buffer created by a renderer.
101
+ def bind_close_keys(editor, buffer_id)
102
+ km = editor.keymap_manager
103
+ return unless km
104
+
105
+ km.bind_buffer(buffer_id, "\e", "rich.close_buffer")
106
+ km.bind_buffer(buffer_id, "<C-c>", "rich.close_buffer")
107
+ end
92
108
  end
93
109
  end
data/lib/ruvim/screen.rb CHANGED
@@ -871,15 +871,19 @@ module RuVim
871
871
  end
872
872
 
873
873
  def search_bg_seq(editor)
874
- truecolor_enabled?(editor) ? "\e[48;2;255;215;0m" : "\e[43m"
874
+ term_color(editor, "\e[48;2;255;215;0m", "\e[43m")
875
875
  end
876
876
 
877
877
  def colorcolumn_bg_seq(editor)
878
- truecolor_enabled?(editor) ? "\e[48;2;72;72;72m" : "\e[48;5;238m"
878
+ term_color(editor, "\e[48;2;72;72;72m", "\e[48;5;238m")
879
879
  end
880
880
 
881
881
  def cursorline_bg_seq(editor)
882
- truecolor_enabled?(editor) ? "\e[48;2;58;58;58m" : "\e[48;5;236m"
882
+ term_color(editor, "\e[48;2;58;58;58m", "\e[48;5;236m")
883
+ end
884
+
885
+ def term_color(editor, truecolor_seq, fallback_seq)
886
+ truecolor_enabled?(editor) ? truecolor_seq : fallback_seq
883
887
  end
884
888
 
885
889
  def line_number_fg_seq(editor, current_line: false)
@@ -911,7 +915,7 @@ module RuVim
911
915
 
912
916
  path = buffer.display_name
913
917
  mod = buffer.modified? ? " [+]" : ""
914
- stream = stream_status_token(buffer)
918
+ stream = buffer.stream_status ? " [#{buffer.stream_status}]" : ""
915
919
  loading = file_loading_status_token(buffer)
916
920
  tab = tab_status_token(editor)
917
921
  msg = editor.message_error? ? "" : editor.message.to_s
@@ -921,14 +925,6 @@ module RuVim
921
925
  "#{compose_status_body(left, msg, body_width)}#{right}"
922
926
  end
923
927
 
924
- def stream_status_token(buffer)
925
- return "" unless buffer.respond_to?(:stream_state)
926
- return "" unless buffer.kind == :stream
927
-
928
- state = (buffer.stream_state || :live).to_s
929
- " [stdin/#{state}]"
930
- end
931
-
932
928
  def file_loading_status_token(buffer)
933
929
  return "" unless buffer.respond_to?(:loading_state)
934
930
  return "" unless buffer.file_buffer?
@@ -1130,6 +1126,7 @@ module RuVim
1130
1126
  search = editor.last_search
1131
1127
  return {} unless search && search[:pattern]
1132
1128
  return {} unless editor.effective_option("hlsearch")
1129
+ return {} if editor.hlsearch_suppressed?
1133
1130
 
1134
1131
  regex = build_screen_search_regex(editor, search[:pattern])
1135
1132
  cols = {}
data/lib/ruvim/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RuVim
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/ruvim.rb CHANGED
@@ -18,6 +18,7 @@ require_relative "ruvim/lang/markdown"
18
18
  require_relative "ruvim/lang/ruby"
19
19
  require_relative "ruvim/lang/json"
20
20
  require_relative "ruvim/lang/scheme"
21
+ require_relative "ruvim/lang/diff"
21
22
  require_relative "ruvim/highlighter"
22
23
  require_relative "ruvim/context"
23
24
  require_relative "ruvim/buffer"
@@ -25,6 +26,13 @@ require_relative "ruvim/window"
25
26
  require_relative "ruvim/editor"
26
27
  require_relative "ruvim/command_registry"
27
28
  require_relative "ruvim/ex_command_registry"
29
+ require_relative "ruvim/git/blame"
30
+ require_relative "ruvim/git/status"
31
+ require_relative "ruvim/git/diff"
32
+ require_relative "ruvim/git/log"
33
+ require_relative "ruvim/git/branch"
34
+ require_relative "ruvim/git/commit"
35
+ require_relative "ruvim/git/handler"
28
36
  require_relative "ruvim/global_commands"
29
37
  require_relative "ruvim/dispatcher"
30
38
  require_relative "ruvim/keymap_manager"
@@ -35,6 +43,8 @@ require_relative "ruvim/rich_view"
35
43
 
36
44
  # Register renderers after RichView is defined
37
45
  RuVim::RichView.register("markdown", RuVim::RichView::MarkdownRenderer)
46
+ RuVim::RichView.register("json", RuVim::RichView::JsonRenderer)
47
+ RuVim::RichView.register("jsonl", RuVim::RichView::JsonlRenderer)
38
48
 
39
49
  require_relative "ruvim/lang/tsv"
40
50
  require_relative "ruvim/lang/csv"
@@ -210,4 +210,29 @@ class AppCompletionTest < Minitest::Test
210
210
  @app.send(:handle_insert_key, :ctrl_n)
211
211
  assert_equal "foobar", b.line_at(0)
212
212
  end
213
+
214
+ def test_git_subcommand_completion
215
+ @editor.materialize_intro_buffer!
216
+ @editor.enter_command_line_mode(":")
217
+ cmd = @editor.command_line
218
+ cmd.replace_text("git bl")
219
+
220
+ @app.send(:command_line_complete)
221
+
222
+ # "bl" matches "blame", "blameback", "blamecommit", "blameprev", "branch"
223
+ # With multiple matches, wildmode applies (longest common prefix or cycle)
224
+ text = cmd.text
225
+ assert text.start_with?("git bl"), "Expected completion to keep 'git bl' prefix, got: #{text}"
226
+ end
227
+
228
+ def test_git_subcommand_completion_unique
229
+ @editor.materialize_intro_buffer!
230
+ @editor.enter_command_line_mode(":")
231
+ cmd = @editor.command_line
232
+ cmd.replace_text("git co")
233
+
234
+ @app.send(:command_line_complete)
235
+
236
+ assert_equal "git commit", cmd.text
237
+ end
213
238
  end
@@ -695,6 +695,28 @@ class AppScenarioTest < Minitest::Test
695
695
  assert_equal :normal, @editor.mode
696
696
  end
697
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
+
698
720
  def test_batch_insert_stops_on_escape
699
721
  @editor.current_buffer.replace_all_lines!([""])
700
722
  # Escape exits insert mode; subsequent keys are normal-mode commands
@@ -1072,4 +1094,151 @@ class AppScenarioTest < Minitest::Test
1072
1094
  assert_equal :hsplit, tree[:type]
1073
1095
  assert_equal 3, tree[:children].length
1074
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
1075
1244
  end
data/test/cli_test.rb CHANGED
@@ -153,6 +153,20 @@ class CLITest < Minitest::Test
153
153
  assert_equal "", err.string
154
154
  end
155
155
 
156
+ def test_parse_follow_option
157
+ opts = RuVim::CLI.parse(["-f", "log.txt"])
158
+
159
+ assert_equal ["log.txt"], opts.files
160
+ assert_equal true, opts.follow
161
+ end
162
+
163
+ def test_help_mentions_follow_option
164
+ out = StringIO.new
165
+ err = StringIO.new
166
+ RuVim::CLI.run(["--help"], stdout: out, stderr: err, stdin: StringIO.new)
167
+ assert_match(/-f\s+Open file in follow mode/, out.string)
168
+ end
169
+
156
170
  def test_run_returns_error_for_missing_config_file
157
171
  out = StringIO.new
158
172
  err = StringIO.new