ruvim 0.3.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +68 -7
  3. data/README.md +30 -7
  4. data/Rakefile +7 -0
  5. data/benchmark/cext_compare.rb +165 -0
  6. data/benchmark/chunked_load.rb +256 -0
  7. data/benchmark/file_load.rb +140 -0
  8. data/benchmark/hotspots.rb +178 -0
  9. data/docs/binding.md +18 -1
  10. data/docs/command.md +156 -10
  11. data/docs/config.md +10 -2
  12. data/docs/done.md +23 -0
  13. data/docs/spec.md +162 -25
  14. data/docs/todo.md +9 -0
  15. data/docs/tutorial.md +33 -1
  16. data/docs/vim_diff.md +31 -8
  17. data/ext/ruvim/extconf.rb +5 -0
  18. data/ext/ruvim/ruvim_ext.c +519 -0
  19. data/lib/ruvim/app.rb +246 -2525
  20. data/lib/ruvim/browser.rb +104 -0
  21. data/lib/ruvim/buffer.rb +43 -20
  22. data/lib/ruvim/cli.rb +6 -0
  23. data/lib/ruvim/command_invocation.rb +2 -2
  24. data/lib/ruvim/completion_manager.rb +708 -0
  25. data/lib/ruvim/dispatcher.rb +14 -8
  26. data/lib/ruvim/display_width.rb +91 -45
  27. data/lib/ruvim/editor.rb +74 -80
  28. data/lib/ruvim/ex_command_registry.rb +3 -1
  29. data/lib/ruvim/file_watcher.rb +243 -0
  30. data/lib/ruvim/gh/link.rb +207 -0
  31. data/lib/ruvim/git/blame.rb +255 -0
  32. data/lib/ruvim/git/branch.rb +112 -0
  33. data/lib/ruvim/git/commit.rb +102 -0
  34. data/lib/ruvim/git/diff.rb +129 -0
  35. data/lib/ruvim/git/grep.rb +107 -0
  36. data/lib/ruvim/git/handler.rb +125 -0
  37. data/lib/ruvim/git/log.rb +41 -0
  38. data/lib/ruvim/git/status.rb +103 -0
  39. data/lib/ruvim/global_commands.rb +351 -77
  40. data/lib/ruvim/highlighter.rb +4 -11
  41. data/lib/ruvim/input.rb +1 -0
  42. data/lib/ruvim/key_handler.rb +1510 -0
  43. data/lib/ruvim/keymap_manager.rb +7 -7
  44. data/lib/ruvim/lang/base.rb +5 -0
  45. data/lib/ruvim/lang/c.rb +116 -0
  46. data/lib/ruvim/lang/cpp.rb +107 -0
  47. data/lib/ruvim/lang/csv.rb +4 -1
  48. data/lib/ruvim/lang/diff.rb +43 -0
  49. data/lib/ruvim/lang/dockerfile.rb +36 -0
  50. data/lib/ruvim/lang/elixir.rb +85 -0
  51. data/lib/ruvim/lang/erb.rb +30 -0
  52. data/lib/ruvim/lang/go.rb +83 -0
  53. data/lib/ruvim/lang/html.rb +34 -0
  54. data/lib/ruvim/lang/javascript.rb +83 -0
  55. data/lib/ruvim/lang/json.rb +40 -0
  56. data/lib/ruvim/lang/lua.rb +76 -0
  57. data/lib/ruvim/lang/makefile.rb +36 -0
  58. data/lib/ruvim/lang/markdown.rb +3 -4
  59. data/lib/ruvim/lang/ocaml.rb +77 -0
  60. data/lib/ruvim/lang/perl.rb +91 -0
  61. data/lib/ruvim/lang/python.rb +85 -0
  62. data/lib/ruvim/lang/registry.rb +102 -0
  63. data/lib/ruvim/lang/ruby.rb +7 -0
  64. data/lib/ruvim/lang/rust.rb +95 -0
  65. data/lib/ruvim/lang/scheme.rb +5 -0
  66. data/lib/ruvim/lang/sh.rb +76 -0
  67. data/lib/ruvim/lang/sql.rb +52 -0
  68. data/lib/ruvim/lang/toml.rb +36 -0
  69. data/lib/ruvim/lang/tsv.rb +4 -1
  70. data/lib/ruvim/lang/typescript.rb +53 -0
  71. data/lib/ruvim/lang/yaml.rb +62 -0
  72. data/lib/ruvim/rich_view/json_renderer.rb +131 -0
  73. data/lib/ruvim/rich_view/jsonl_renderer.rb +57 -0
  74. data/lib/ruvim/rich_view/table_renderer.rb +3 -3
  75. data/lib/ruvim/rich_view.rb +30 -7
  76. data/lib/ruvim/screen.rb +135 -84
  77. data/lib/ruvim/stream/file_load.rb +85 -0
  78. data/lib/ruvim/stream/follow.rb +40 -0
  79. data/lib/ruvim/stream/git.rb +43 -0
  80. data/lib/ruvim/stream/run.rb +74 -0
  81. data/lib/ruvim/stream/stdin.rb +55 -0
  82. data/lib/ruvim/stream.rb +35 -0
  83. data/lib/ruvim/stream_mixer.rb +394 -0
  84. data/lib/ruvim/terminal.rb +18 -4
  85. data/lib/ruvim/text_metrics.rb +84 -65
  86. data/lib/ruvim/version.rb +1 -1
  87. data/lib/ruvim/window.rb +5 -5
  88. data/lib/ruvim.rb +31 -4
  89. data/test/app_command_test.rb +382 -0
  90. data/test/app_completion_test.rb +65 -16
  91. data/test/app_dot_repeat_test.rb +27 -3
  92. data/test/app_ex_command_test.rb +154 -0
  93. data/test/app_motion_test.rb +13 -12
  94. data/test/app_register_test.rb +2 -1
  95. data/test/app_scenario_test.rb +182 -8
  96. data/test/app_startup_test.rb +70 -27
  97. data/test/app_text_object_test.rb +2 -1
  98. data/test/app_unicode_behavior_test.rb +3 -2
  99. data/test/browser_test.rb +88 -0
  100. data/test/buffer_test.rb +24 -0
  101. data/test/cli_test.rb +77 -0
  102. data/test/clipboard_test.rb +67 -0
  103. data/test/command_invocation_test.rb +33 -0
  104. data/test/command_line_test.rb +118 -0
  105. data/test/config_dsl_test.rb +134 -0
  106. data/test/dispatcher_test.rb +74 -4
  107. data/test/display_width_test.rb +41 -0
  108. data/test/ex_command_registry_test.rb +106 -0
  109. data/test/file_watcher_test.rb +197 -0
  110. data/test/follow_test.rb +198 -0
  111. data/test/gh_link_test.rb +141 -0
  112. data/test/git_blame_test.rb +792 -0
  113. data/test/git_grep_test.rb +64 -0
  114. data/test/highlighter_test.rb +169 -0
  115. data/test/indent_test.rb +223 -0
  116. data/test/input_screen_integration_test.rb +1 -1
  117. data/test/keyword_chars_test.rb +85 -0
  118. data/test/lang_test.rb +634 -0
  119. data/test/markdown_renderer_test.rb +5 -5
  120. data/test/on_save_hook_test.rb +12 -8
  121. data/test/render_snapshot_test.rb +78 -0
  122. data/test/rich_view_test.rb +279 -23
  123. data/test/run_command_test.rb +307 -0
  124. data/test/screen_test.rb +68 -5
  125. data/test/search_option_test.rb +19 -0
  126. data/test/stream_test.rb +165 -0
  127. data/test/test_helper.rb +9 -0
  128. data/test/window_test.rb +59 -0
  129. metadata +68 -2
@@ -4,15 +4,21 @@ module RuVim
4
4
  module TextMetrics
5
5
  module_function
6
6
 
7
- Cell = Struct.new(:glyph, :source_col, :display_width, keyword_init: true)
7
+ class Cell
8
+ attr_reader :glyph, :source_col, :display_width
9
+
10
+ def initialize(glyph, source_col, display_width)
11
+ @glyph = glyph
12
+ @source_col = source_col
13
+ @display_width = display_width
14
+ end
15
+ end
8
16
 
9
- # Cursor positions in RuVim are currently "character index" (Ruby String#[] index on UTF-8),
10
- # not byte offsets. Grapheme-aware movement is layered on top of that.
11
17
  def previous_grapheme_char_index(line, char_index)
12
- idx = [char_index.to_i, 0].max
18
+ idx = [char_index, 0].max
13
19
  return 0 if idx <= 0
14
20
 
15
- left = line.to_s[0...idx].to_s
21
+ left = line[0...idx].to_s
16
22
  clusters = left.scan(/\X/)
17
23
  return 0 if clusters.empty?
18
24
 
@@ -21,7 +27,7 @@ module RuVim
21
27
 
22
28
  def next_grapheme_char_index(line, char_index)
23
29
  s = line.to_s
24
- idx = [[char_index.to_i, 0].max, s.length].min
30
+ idx = [[char_index, 0].max, s.length].min
25
31
  return s.length if idx >= s.length
26
32
 
27
33
  rest = s[idx..].to_s
@@ -32,93 +38,106 @@ module RuVim
32
38
  end
33
39
 
34
40
  def screen_col_for_char_index(line, char_index, tabstop: 2)
35
- idx = [char_index.to_i, 0].max
36
- prefix = line.to_s[0...idx].to_s
41
+ idx = [char_index, 0].max
42
+ prefix = line[0...idx].to_s
37
43
  RuVim::DisplayWidth.display_width(prefix, tabstop:)
38
44
  end
39
45
 
40
- # Returns a character index whose screen column is <= target_screen_col,
41
- # aligned to a grapheme-cluster boundary.
42
- def char_index_for_screen_col(line, target_screen_col, tabstop: 2, align: :floor)
43
- s = line.to_s
44
- target = [target_screen_col.to_i, 0].max
45
- screen_col = 0
46
- char_index = 0
47
-
48
- s.scan(/\X/).each do |cluster|
49
- width = RuVim::DisplayWidth.display_width(cluster, tabstop:, start_col: screen_col)
50
- if screen_col + width > target
51
- return align == :ceil ? (char_index + cluster.length) : char_index
52
- end
46
+ if defined?(RuVim::TextMetricsExt)
47
+ # ---- C extension paths ----
53
48
 
54
- screen_col += width
55
- char_index += cluster.length
49
+ def clip_cells_for_width(text, width, source_col_start: 0, tabstop: 2)
50
+ TextMetricsExt.clip_cells_for_width(text, width, source_col_start:, tabstop:)
56
51
  end
57
52
 
58
- char_index
59
- end
53
+ def char_index_for_screen_col(line, target_screen_col, tabstop: 2, align: :floor)
54
+ TextMetricsExt.char_index_for_screen_col(line, target_screen_col, tabstop:, align:)
55
+ end
56
+ else
57
+ # ---- Pure Ruby fallback ----
58
+
59
+ def char_index_for_screen_col(line, target_screen_col, tabstop: 2, align: :floor)
60
+ s = line.to_s
61
+ target = [target_screen_col, 0].max
62
+ screen_col = 0
63
+ char_index = 0
64
+
65
+ s.scan(/\X/).each do |cluster|
66
+ width = RuVim::DisplayWidth.display_width(cluster, tabstop:, start_col: screen_col)
67
+ if screen_col + width > target
68
+ return align == :ceil ? (char_index + cluster.length) : char_index
69
+ end
60
70
 
61
- def clip_cells_for_width(text, width, source_col_start: 0, tabstop: 2)
62
- max_width = [width.to_i, 0].max
63
- cells = []
64
- display_col = 0
65
- source_col = source_col_start.to_i
71
+ screen_col += width
72
+ char_index += cluster.length
73
+ end
66
74
 
67
- text.to_s.each_char do |ch|
68
- if ch == "\t"
69
- w = RuVim::DisplayWidth.cell_width(ch, col: display_col, tabstop:)
70
- break if display_col + w > max_width
75
+ char_index
76
+ end
71
77
 
72
- w.times do
73
- cells << Cell.new(glyph: " ", source_col:, display_width: 1)
78
+ def clip_cells_for_width(text, width, source_col_start: 0, tabstop: 2)
79
+ max_width = [width, 0].max
80
+ cells = []
81
+ display_col = 0
82
+ source_col = source_col_start
83
+
84
+ text.to_s.each_char do |ch|
85
+ code = ch.ord
86
+ # Fast path: printable ASCII (0x20..0x7E) — width 1, no special handling
87
+ if code >= 0x20 && code <= 0x7E
88
+ break if display_col >= max_width
89
+ cells << Cell.new(ch, source_col, 1)
90
+ display_col += 1
91
+ source_col += 1
92
+ next
93
+ end
94
+
95
+ if ch == "\t"
96
+ w = tabstop - (display_col % tabstop)
97
+ w = tabstop if w.zero?
98
+ break if display_col + w > max_width
99
+
100
+ w.times do
101
+ cells << Cell.new(" ", source_col, 1)
102
+ end
103
+ display_col += w
104
+ source_col += 1
105
+ next
74
106
  end
75
- display_col += w
76
- source_col += 1
77
- next
78
- end
79
107
 
80
- w = RuVim::DisplayWidth.cell_width(ch, col: display_col, tabstop:)
81
- if terminal_unsafe_control_char?(ch)
82
- w = [w, 1].max
108
+ # Control chars (0x00..0x1F, 0x7F, 0x80..0x9F)
109
+ if code < 0x20 || code == 0x7F || (code >= 0x80 && code <= 0x9F)
110
+ break if display_col >= max_width
111
+ cells << Cell.new("?", source_col, 1)
112
+ display_col += 1
113
+ source_col += 1
114
+ next
115
+ end
116
+
117
+ w = RuVim::DisplayWidth.cell_width(ch, col: display_col, tabstop:)
83
118
  break if display_col + w > max_width
84
119
 
85
- cells << Cell.new(glyph: terminal_safe_placeholder(ch), source_col:, display_width: w)
120
+ cells << Cell.new(ch, source_col, w)
86
121
  display_col += w
87
122
  source_col += 1
88
- next
89
123
  end
90
- break if display_col + w > max_width
91
124
 
92
- cells << Cell.new(glyph: ch, source_col:, display_width: w)
93
- display_col += w
94
- source_col += 1
125
+ [cells, display_col]
95
126
  end
96
-
97
- [cells, display_col]
98
127
  end
99
128
 
100
129
  def pad_plain_to_screen_width(text, width, tabstop: 2)
101
130
  cells, used = clip_cells_for_width(text, width, tabstop:)
102
131
  out = cells.map(&:glyph).join
103
- out << (" " * [width.to_i - used, 0].max)
132
+ out << (" " * [width - used, 0].max)
104
133
  out
105
134
  end
106
135
 
107
- def terminal_safe_text(text)
108
- text.to_s.each_char.map { |ch| terminal_unsafe_control_char?(ch) ? terminal_safe_placeholder(ch) : ch }.join
109
- end
110
-
111
- def terminal_unsafe_control_char?(ch)
112
- return false if ch.nil? || ch.empty? || ch == "\t"
136
+ UNSAFE_CONTROL_CHAR_RE = /[\u0000-\u0008\u000a-\u001f\u007f\u0080-\u009f]/
113
137
 
114
- code = ch.ord
115
- (code >= 0x00 && code < 0x20) || code == 0x7F || (0x80..0x9F).cover?(code)
116
- rescue StandardError
117
- false
138
+ def terminal_safe_text(text)
139
+ text.to_s.gsub(UNSAFE_CONTROL_CHAR_RE, "?")
118
140
  end
119
141
 
120
- def terminal_safe_placeholder(_ch)
121
- "?"
122
- end
123
142
  end
124
143
  end
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.6.0"
5
5
  end
data/lib/ruvim/window.rb CHANGED
@@ -19,17 +19,17 @@ module RuVim
19
19
  end
20
20
 
21
21
  def cursor_x=(value)
22
- @cursor_x = value.to_i
22
+ @cursor_x = value
23
23
  @preferred_x = nil
24
24
  end
25
25
 
26
26
  def cursor_y=(value)
27
- @cursor_y = value.to_i
27
+ @cursor_y = value
28
28
  end
29
29
 
30
30
  def clamp_to_buffer(buffer, max_extra_col: 0)
31
31
  @cursor_y = [[@cursor_y, 0].max, buffer.line_count - 1].min
32
- max_col = buffer.line_length(@cursor_y) + [max_extra_col.to_i, 0].max
32
+ max_col = buffer.line_length(@cursor_y) + [max_extra_col, 0].max
33
33
  @cursor_x = [[@cursor_x, 0].max, max_col].min
34
34
  self
35
35
  end
@@ -71,7 +71,7 @@ module RuVim
71
71
 
72
72
  def ensure_visible(buffer, height:, width:, tabstop: 2, scrolloff: 0, sidescrolloff: 0)
73
73
  clamp_to_buffer(buffer)
74
- so = [[scrolloff.to_i, 0].max, [height.to_i - 1, 0].max].min
74
+ so = [[scrolloff, 0].max, [height - 1, 0].max].min
75
75
 
76
76
  top_target = @cursor_y - so
77
77
  bottom_target = @cursor_y + so
@@ -82,7 +82,7 @@ module RuVim
82
82
  line = buffer.line_at(@cursor_y)
83
83
  cursor_screen_col = RuVim::TextMetrics.screen_col_for_char_index(line, @cursor_x, tabstop:)
84
84
  offset_screen_col = RuVim::TextMetrics.screen_col_for_char_index(line, @col_offset, tabstop:)
85
- sso = [[sidescrolloff.to_i, 0].max, [width.to_i - 1, 0].max].min
85
+ sso = [[sidescrolloff, 0].max, [width - 1, 0].max].min
86
86
 
87
87
  if cursor_screen_col < offset_screen_col + sso
88
88
  target_left = [cursor_screen_col - sso, 0].max
data/lib/ruvim.rb CHANGED
@@ -13,11 +13,33 @@ require_relative "ruvim/display_width"
13
13
  require_relative "ruvim/keyword_chars"
14
14
  require_relative "ruvim/text_metrics"
15
15
  require_relative "ruvim/clipboard"
16
+ require_relative "ruvim/browser"
17
+ require_relative "ruvim/lang/registry"
16
18
  require_relative "ruvim/lang/base"
17
19
  require_relative "ruvim/lang/markdown"
18
20
  require_relative "ruvim/lang/ruby"
19
21
  require_relative "ruvim/lang/json"
20
22
  require_relative "ruvim/lang/scheme"
23
+ require_relative "ruvim/lang/c"
24
+ require_relative "ruvim/lang/cpp"
25
+ require_relative "ruvim/lang/diff"
26
+ require_relative "ruvim/lang/yaml"
27
+ require_relative "ruvim/lang/sh"
28
+ require_relative "ruvim/lang/python"
29
+ require_relative "ruvim/lang/javascript"
30
+ require_relative "ruvim/lang/typescript"
31
+ require_relative "ruvim/lang/html"
32
+ require_relative "ruvim/lang/toml"
33
+ require_relative "ruvim/lang/go"
34
+ require_relative "ruvim/lang/rust"
35
+ require_relative "ruvim/lang/makefile"
36
+ require_relative "ruvim/lang/dockerfile"
37
+ require_relative "ruvim/lang/sql"
38
+ require_relative "ruvim/lang/elixir"
39
+ require_relative "ruvim/lang/perl"
40
+ require_relative "ruvim/lang/lua"
41
+ require_relative "ruvim/lang/ocaml"
42
+ require_relative "ruvim/lang/erb"
21
43
  require_relative "ruvim/highlighter"
22
44
  require_relative "ruvim/context"
23
45
  require_relative "ruvim/buffer"
@@ -25,6 +47,15 @@ require_relative "ruvim/window"
25
47
  require_relative "ruvim/editor"
26
48
  require_relative "ruvim/command_registry"
27
49
  require_relative "ruvim/ex_command_registry"
50
+ require_relative "ruvim/git/blame"
51
+ require_relative "ruvim/git/status"
52
+ require_relative "ruvim/git/diff"
53
+ require_relative "ruvim/git/log"
54
+ require_relative "ruvim/git/branch"
55
+ require_relative "ruvim/git/commit"
56
+ require_relative "ruvim/git/grep"
57
+ require_relative "ruvim/gh/link"
58
+ require_relative "ruvim/git/handler"
28
59
  require_relative "ruvim/global_commands"
29
60
  require_relative "ruvim/dispatcher"
30
61
  require_relative "ruvim/keymap_manager"
@@ -32,10 +63,6 @@ require_relative "ruvim/command_line"
32
63
  require_relative "ruvim/input"
33
64
  require_relative "ruvim/terminal"
34
65
  require_relative "ruvim/rich_view"
35
-
36
- # Register renderers after RichView is defined
37
- RuVim::RichView.register("markdown", RuVim::RichView::MarkdownRenderer)
38
-
39
66
  require_relative "ruvim/lang/tsv"
40
67
  require_relative "ruvim/lang/csv"
41
68
  require_relative "ruvim/screen"
@@ -0,0 +1,382 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class AppCommandTest < Minitest::Test
6
+ def setup
7
+ @app = RuVim::App.new(clean: true)
8
+ @editor = @app.instance_variable_get(:@editor)
9
+ @key_handler = @app.instance_variable_get(:@key_handler)
10
+ @editor.materialize_intro_buffer!
11
+ end
12
+
13
+ def feed(*keys)
14
+ keys.each { |k| @key_handler.handle(k) }
15
+ end
16
+
17
+ def buf
18
+ @editor.current_buffer
19
+ end
20
+
21
+ def win
22
+ @editor.current_window
23
+ end
24
+
25
+ # --- cursor commands ---
26
+
27
+ def test_cursor_line_end
28
+ buf.replace_all_lines!(["hello world"])
29
+ win.cursor_x = 0
30
+ feed("$")
31
+ assert_equal buf.line_length(0), win.cursor_x
32
+ end
33
+
34
+ def test_cursor_buffer_start_gg
35
+ buf.replace_all_lines!(["line1", "line2", "line3"])
36
+ win.cursor_y = 2
37
+ feed("g", "g")
38
+ assert_equal 0, win.cursor_y
39
+ end
40
+
41
+ # --- insert mode variants ---
42
+
43
+ def test_append_mode_a
44
+ buf.replace_all_lines!(["abc"])
45
+ win.cursor_x = 1
46
+ feed("a", "X", :escape)
47
+ assert_equal ["abXc"], buf.lines
48
+ end
49
+
50
+ def test_insert_line_start_nonblank_I
51
+ buf.replace_all_lines!([" hello"])
52
+ win.cursor_x = 4
53
+ feed("I", "X", :escape)
54
+ assert_equal [" Xhello"], buf.lines
55
+ end
56
+
57
+ def test_open_line_below_o
58
+ buf.replace_all_lines!(["hello", "world"])
59
+ win.cursor_y = 0
60
+ feed("o", "new", :escape)
61
+ assert_equal ["hello", "new", "world"], buf.lines
62
+ assert_equal :normal, @editor.mode
63
+ end
64
+
65
+ def test_open_line_above_O
66
+ buf.replace_all_lines!(["hello", "world"])
67
+ win.cursor_y = 1
68
+ feed("O", "new", :escape)
69
+ assert_equal ["hello", "new", "world"], buf.lines
70
+ assert_equal :normal, @editor.mode
71
+ end
72
+
73
+ # --- undo / redo ---
74
+
75
+ def test_undo_and_redo
76
+ buf.replace_all_lines!(["hello"])
77
+ feed("x")
78
+ assert_equal ["ello"], buf.lines
79
+
80
+ feed("u")
81
+ assert_equal ["hello"], buf.lines
82
+ assert_match(/Undo/, @editor.message)
83
+
84
+ feed(:ctrl_r)
85
+ assert_equal ["ello"], buf.lines
86
+ assert_match(/Redo/, @editor.message)
87
+ end
88
+
89
+ def test_undo_at_oldest
90
+ buf.replace_all_lines!(["hello"])
91
+ buf.instance_variable_get(:@undo_stack)&.clear
92
+ feed("u")
93
+ assert_match(/oldest/, @editor.message)
94
+ end
95
+
96
+ # --- search ---
97
+
98
+ def test_search_backward_mode
99
+ buf.replace_all_lines!(["hello world"])
100
+ feed("?")
101
+ assert_equal :command_line, @editor.mode
102
+ end
103
+
104
+ def test_search_prev_N
105
+ buf.replace_all_lines!(["aaa", "bbb", "aaa"])
106
+ win.cursor_y = 0
107
+ feed("/", "b", "b", "b", :enter)
108
+ assert_equal 1, win.cursor_y
109
+
110
+ feed("N")
111
+ assert_operator win.cursor_y, :>=, 0
112
+ end
113
+
114
+ def test_search_word_backward
115
+ buf.replace_all_lines!(["foo bar foo"])
116
+ win.cursor_x = 8
117
+ feed("#")
118
+ assert_equal 0, win.cursor_x
119
+ end
120
+
121
+ def test_search_word_forward_partial
122
+ buf.replace_all_lines!(["foobar foo foobar"])
123
+ win.cursor_x = 0
124
+ feed("g", "*")
125
+ assert_operator win.cursor_x, :>, 0
126
+ end
127
+
128
+ def test_search_word_backward_partial
129
+ buf.replace_all_lines!(["foobar foo foobar"])
130
+ win.cursor_x = 11
131
+ feed("g", "#")
132
+ assert_operator win.cursor_x, :<, 11
133
+ end
134
+
135
+ # --- marks ---
136
+
137
+ def test_mark_set_and_jump
138
+ buf.replace_all_lines!(["line1", "line2", "line3"])
139
+ win.cursor_y = 1
140
+ feed("m", "a")
141
+
142
+ win.cursor_y = 0
143
+ feed("'", "a")
144
+ assert_equal 1, win.cursor_y
145
+ end
146
+
147
+ def test_mark_jump_unset
148
+ buf.replace_all_lines!(["line1"])
149
+ feed("`", "z")
150
+ assert_match(/Mark not set/, @editor.message)
151
+ end
152
+
153
+ def test_mark_pending_escape_cancels
154
+ buf.replace_all_lines!(["hello"])
155
+ feed("m", "\e")
156
+ assert_equal :normal, @editor.mode
157
+ end
158
+
159
+ def test_mark_pending_invalid_char
160
+ buf.replace_all_lines!(["hello"])
161
+ feed("m", " ")
162
+ assert_equal :normal, @editor.mode
163
+ end
164
+
165
+ # --- jump list ---
166
+
167
+ def test_jump_older_and_newer
168
+ buf.replace_all_lines!((1..20).map { |i| "line#{i}" })
169
+ win.cursor_y = 0
170
+
171
+ feed("G")
172
+ last_line = buf.line_count - 1
173
+ assert_equal last_line, win.cursor_y
174
+
175
+ feed(:ctrl_o)
176
+ old_y = win.cursor_y
177
+
178
+ feed(:ctrl_i)
179
+ new_y = win.cursor_y
180
+ assert_operator new_y, :>=, old_y
181
+ end
182
+
183
+ def test_jump_older_empty
184
+ buf.replace_all_lines!(["line1"])
185
+ @editor.instance_variable_get(:@jump_list)&.clear rescue nil
186
+ feed(:ctrl_o)
187
+ assert_match(/Jump list/, @editor.message.to_s) if @editor.message
188
+ end
189
+
190
+ def test_backtick_backtick_jumps_older
191
+ buf.replace_all_lines!((1..10).map { |i| "line#{i}" })
192
+ win.cursor_y = 0
193
+ feed("G")
194
+ feed("`", "`")
195
+ assert_equal 0, win.cursor_y
196
+ end
197
+
198
+ def test_jump_pending_escape_cancels
199
+ buf.replace_all_lines!(["hello"])
200
+ feed("'", "\e")
201
+ assert_equal :normal, @editor.mode
202
+ end
203
+
204
+ def test_jump_pending_invalid_mark
205
+ buf.replace_all_lines!(["hello"])
206
+ feed("'", " ")
207
+ assert_equal :normal, @editor.mode
208
+ end
209
+
210
+ # --- visual mode ---
211
+
212
+ def test_visual_line_yank
213
+ buf.replace_all_lines!(["aaa", "bbb", "ccc"])
214
+ win.cursor_y = 0
215
+ feed("V", "j", "y")
216
+
217
+ reg = @editor.get_register("\"")
218
+ assert_equal :normal, @editor.mode
219
+ assert_includes reg[:text], "aaa"
220
+ assert_includes reg[:text], "bbb"
221
+ end
222
+
223
+ def test_visual_line_delete
224
+ buf.replace_all_lines!(["aaa", "bbb", "ccc"])
225
+ win.cursor_y = 0
226
+ feed("V", "j", "d")
227
+
228
+ assert_equal ["ccc"], buf.lines
229
+ assert_equal :normal, @editor.mode
230
+ end
231
+
232
+ def test_visual_char_delete
233
+ buf.replace_all_lines!(["abcdef"])
234
+ win.cursor_x = 1
235
+ feed("v", "l", "l", "d")
236
+
237
+ assert_equal ["aef"], buf.lines
238
+ assert_equal :normal, @editor.mode
239
+ end
240
+
241
+ def test_visual_select_text_object_iw
242
+ buf.replace_all_lines!(["hello world"])
243
+ win.cursor_x = 0
244
+ feed("v", "i", "w")
245
+ assert_equal :visual_char, @editor.mode
246
+ end
247
+
248
+ # --- delete operator ---
249
+
250
+ def test_delete_gg_motion
251
+ buf.replace_all_lines!(["aaa", "bbb", "ccc"])
252
+ win.cursor_y = 2
253
+ feed("d", "g", "g")
254
+ assert_equal [""], buf.lines
255
+ end
256
+
257
+ def test_delete_j_motion
258
+ buf.replace_all_lines!(["aaa", "bbb", "ccc"])
259
+ win.cursor_y = 0
260
+ feed("d", "j")
261
+ assert_equal ["ccc"], buf.lines
262
+ end
263
+
264
+ def test_delete_k_motion
265
+ buf.replace_all_lines!(["aaa", "bbb", "ccc"])
266
+ win.cursor_y = 1
267
+ feed("d", "k")
268
+ assert_equal ["ccc"], buf.lines
269
+ end
270
+
271
+ def test_delete_word_dw
272
+ buf.replace_all_lines!(["hello world"])
273
+ win.cursor_x = 0
274
+ feed("d", "w")
275
+ assert_equal ["world"], buf.lines
276
+ end
277
+
278
+ def test_delete_aw
279
+ buf.replace_all_lines!(["hello world"])
280
+ win.cursor_x = 0
281
+ feed("d", "a", "w")
282
+ assert_equal ["world"], buf.lines
283
+ end
284
+
285
+ # --- yank operator ---
286
+
287
+ def test_yank_word_yw
288
+ buf.replace_all_lines!(["hello world"])
289
+ win.cursor_x = 0
290
+ feed("y", "w")
291
+ reg = @editor.get_register("\"")
292
+ assert_equal "hello ", reg[:text]
293
+ end
294
+
295
+ def test_yank_gg_motion
296
+ buf.replace_all_lines!(["aaa", "bbb", "ccc"])
297
+ win.cursor_y = 2
298
+ feed("y", "g", "g")
299
+ reg = @editor.get_register("\"")
300
+ assert_includes reg[:text], "aaa"
301
+ assert_includes reg[:text], "ccc"
302
+ end
303
+
304
+ def test_yank_iw
305
+ buf.replace_all_lines!(["hello world"])
306
+ win.cursor_x = 0
307
+ feed("y", "i", "w")
308
+ reg = @editor.get_register("\"")
309
+ assert_equal "hello", reg[:text]
310
+ end
311
+
312
+ def test_yank_aw
313
+ buf.replace_all_lines!(["hello world"])
314
+ win.cursor_x = 0
315
+ feed("y", "a", "w")
316
+ reg = @editor.get_register("\"")
317
+ assert_equal "hello ", reg[:text]
318
+ end
319
+
320
+ # --- indent operator ---
321
+
322
+ def test_indent_j_motion
323
+ buf.replace_all_lines!([" aaa", " bbb", "ccc"])
324
+ win.cursor_y = 0
325
+ feed("=", "j")
326
+ assert_equal :normal, @editor.mode
327
+ end
328
+
329
+ def test_indent_gg_motion
330
+ buf.replace_all_lines!([" aaa", " bbb"])
331
+ win.cursor_y = 1
332
+ feed("=", "g", "g")
333
+ assert_equal :normal, @editor.mode
334
+ end
335
+
336
+ def test_indent_k_motion
337
+ buf.replace_all_lines!(["aaa", " bbb", "ccc"])
338
+ win.cursor_y = 1
339
+ feed("=", "k")
340
+ assert_equal :normal, @editor.mode
341
+ end
342
+
343
+ def test_indent_G_motion
344
+ buf.replace_all_lines!([" aaa", " bbb"])
345
+ win.cursor_y = 0
346
+ feed("=", "G")
347
+ assert_equal :normal, @editor.mode
348
+ end
349
+
350
+ # --- arrow and page keys ---
351
+
352
+ def test_arrow_keys_in_normal_mode
353
+ buf.replace_all_lines!(["abc", "def"])
354
+ win.cursor_y = 0
355
+ win.cursor_x = 0
356
+ feed(:right)
357
+ assert_equal 1, win.cursor_x
358
+ feed(:down)
359
+ assert_equal 1, win.cursor_y
360
+ feed(:left)
361
+ assert_equal 0, win.cursor_x
362
+ feed(:up)
363
+ assert_equal 0, win.cursor_y
364
+ end
365
+
366
+ def test_page_keys_in_normal_mode
367
+ buf.replace_all_lines!((1..30).map { |i| "line#{i}" })
368
+ @editor.current_window_view_height_hint = 10
369
+ win.cursor_y = 0
370
+ feed(:pagedown)
371
+ assert_operator win.cursor_y, :>, 0
372
+ feed(:pageup)
373
+ assert_equal 0, win.cursor_y
374
+ end
375
+
376
+ # --- escape clears message ---
377
+
378
+ def test_escape_clears_message
379
+ @editor.echo("test message")
380
+ feed("\e")
381
+ end
382
+ end