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,67 @@
1
+ require_relative "test_helper"
2
+
3
+ class ClipboardTest < Minitest::Test
4
+ def setup
5
+ RuVim::Clipboard.reset_backend!
6
+ end
7
+
8
+ def teardown
9
+ RuVim::Clipboard.reset_backend!
10
+ end
11
+
12
+ def test_available_returns_true_when_backend_exists
13
+ RuVim::Clipboard.instance_variable_set(:@backend, { write: %w[echo], read: %w[echo] })
14
+ assert RuVim::Clipboard.available?
15
+ end
16
+
17
+ def test_available_returns_false_when_no_backend
18
+ RuVim::Clipboard.instance_variable_set(:@backend, false)
19
+ refute RuVim::Clipboard.available?
20
+ end
21
+
22
+ def test_read_returns_nil_when_no_read_cmd
23
+ RuVim::Clipboard.instance_variable_set(:@backend, { write: nil, read: nil })
24
+ assert_nil RuVim::Clipboard.read
25
+ end
26
+
27
+ def test_read_returns_output_on_success
28
+ RuVim::Clipboard.instance_variable_set(:@backend, { write: %w[true], read: %w[echo hello] })
29
+ result = RuVim::Clipboard.read
30
+ assert_equal "hello\n", result
31
+ end
32
+
33
+ def test_read_returns_nil_on_command_failure
34
+ RuVim::Clipboard.instance_variable_set(:@backend, { write: %w[true], read: %w[false] })
35
+ assert_nil RuVim::Clipboard.read
36
+ end
37
+
38
+ def test_write_returns_false_when_no_write_cmd
39
+ RuVim::Clipboard.instance_variable_set(:@backend, { write: nil, read: nil })
40
+ refute RuVim::Clipboard.write("test")
41
+ end
42
+
43
+ def test_write_sends_text_to_command
44
+ RuVim::Clipboard.instance_variable_set(:@backend, { write: %w[cat], read: %w[true] })
45
+ assert RuVim::Clipboard.write("hello")
46
+ end
47
+
48
+ def test_pbcopy_backend_format
49
+ expected = { write: %w[pbcopy], read: %w[pbpaste] }
50
+ assert_equal expected, RuVim::Clipboard.pbcopy_backend
51
+ end
52
+
53
+ def test_wayland_backend_format
54
+ expected = { write: %w[wl-copy], read: %w[wl-paste -n] }
55
+ assert_equal expected, RuVim::Clipboard.wayland_backend
56
+ end
57
+
58
+ def test_xclip_backend_format
59
+ expected = { write: %w[xclip -selection clipboard -in], read: %w[xclip -selection clipboard -out] }
60
+ assert_equal expected, RuVim::Clipboard.xclip_backend
61
+ end
62
+
63
+ def test_xsel_backend_format
64
+ expected = { write: %w[xsel --clipboard --input], read: %w[xsel --clipboard --output] }
65
+ assert_equal expected, RuVim::Clipboard.xsel_backend
66
+ end
67
+ end
@@ -0,0 +1,118 @@
1
+ require_relative "test_helper"
2
+
3
+ class CommandLineTest < Minitest::Test
4
+ def setup
5
+ @cl = RuVim::CommandLine.new
6
+ end
7
+
8
+ def test_initial_state
9
+ assert_equal ":", @cl.prefix
10
+ assert_equal "", @cl.text
11
+ assert_equal 0, @cl.cursor
12
+ end
13
+
14
+ def test_reset_with_custom_prefix
15
+ @cl.reset(prefix: "/")
16
+ assert_equal "/", @cl.prefix
17
+ assert_equal "", @cl.text
18
+ assert_equal 0, @cl.cursor
19
+ end
20
+
21
+ def test_insert_appends_at_cursor
22
+ @cl.insert("abc")
23
+ assert_equal "abc", @cl.text
24
+ assert_equal 3, @cl.cursor
25
+ end
26
+
27
+ def test_insert_at_middle
28
+ @cl.insert("ac")
29
+ @cl.instance_variable_set(:@cursor, 1)
30
+ @cl.insert("b")
31
+ assert_equal "abc", @cl.text
32
+ assert_equal 2, @cl.cursor
33
+ end
34
+
35
+ def test_backspace_at_zero_does_nothing
36
+ @cl.backspace
37
+ assert_equal "", @cl.text
38
+ assert_equal 0, @cl.cursor
39
+ end
40
+
41
+ def test_backspace_deletes_character
42
+ @cl.insert("abc")
43
+ @cl.backspace
44
+ assert_equal "ab", @cl.text
45
+ assert_equal 2, @cl.cursor
46
+ end
47
+
48
+ def test_move_left
49
+ @cl.insert("abc")
50
+ @cl.move_left
51
+ assert_equal 2, @cl.cursor
52
+ end
53
+
54
+ def test_move_left_at_zero_stays
55
+ @cl.move_left
56
+ assert_equal 0, @cl.cursor
57
+ end
58
+
59
+ def test_move_right
60
+ @cl.insert("abc")
61
+ @cl.instance_variable_set(:@cursor, 1)
62
+ @cl.move_right
63
+ assert_equal 2, @cl.cursor
64
+ end
65
+
66
+ def test_move_right_at_end_stays
67
+ @cl.insert("abc")
68
+ @cl.move_right
69
+ assert_equal 3, @cl.cursor
70
+ end
71
+
72
+ def test_content_includes_prefix
73
+ @cl.insert("hello")
74
+ assert_equal ":hello", @cl.content
75
+ end
76
+
77
+ def test_content_with_custom_prefix
78
+ @cl.reset(prefix: "/")
79
+ @cl.insert("search")
80
+ assert_equal "/search", @cl.content
81
+ end
82
+
83
+ def test_clear_resets_text_and_cursor
84
+ @cl.insert("hello")
85
+ @cl.clear
86
+ assert_equal "", @cl.text
87
+ assert_equal 0, @cl.cursor
88
+ assert_equal ":", @cl.prefix
89
+ end
90
+
91
+ def test_replace_text
92
+ @cl.insert("old")
93
+ @cl.replace_text("new text")
94
+ assert_equal "new text", @cl.text
95
+ assert_equal 8, @cl.cursor
96
+ end
97
+
98
+ def test_replace_span_end_cursor
99
+ @cl.insert("hello world")
100
+ @cl.replace_span(6, 11, "ruby")
101
+ assert_equal "hello ruby", @cl.text
102
+ assert_equal 10, @cl.cursor
103
+ end
104
+
105
+ def test_replace_span_start_cursor
106
+ @cl.insert("hello world")
107
+ @cl.replace_span(6, 11, "ruby", cursor_at: :start)
108
+ assert_equal "hello ruby", @cl.text
109
+ assert_equal 6, @cl.cursor
110
+ end
111
+
112
+ def test_replace_span_integer_cursor
113
+ @cl.insert("hello world")
114
+ @cl.replace_span(6, 11, "ruby", cursor_at: 8)
115
+ assert_equal "hello ruby", @cl.text
116
+ assert_equal 8, @cl.cursor
117
+ end
118
+ end
@@ -75,4 +75,91 @@ class ConfigDSLTest < Minitest::Test
75
75
  assert_equal :match, match.status
76
76
  assert match.invocation.id.start_with?("user.keymap.global.")
77
77
  end
78
+
79
+ def test_nmap_with_command_id_string
80
+ @command_registry.register("test.cmd", call: ->(_ctx, **) {}, desc: "test", source: :builtin)
81
+ @dsl.nmap("T", "test.cmd")
82
+ match = @keymaps.resolve(:normal, ["T"])
83
+ assert_equal :match, match.status
84
+ assert_equal "test.cmd", match.invocation.id
85
+ end
86
+
87
+ def test_nmap_without_command_id_or_block_raises
88
+ # ConfigDSL < BasicObject, so raise becomes NoMethodError for ::ArgumentError
89
+ assert_raises(NoMethodError, ArgumentError) { @dsl.nmap("T") }
90
+ end
91
+
92
+ def test_imap_without_block_or_id_raises
93
+ assert_raises(NoMethodError, ArgumentError) { @dsl.imap("T") }
94
+ end
95
+
96
+ def test_command_registers_user_command
97
+ @dsl.command("my.cmd", desc: "custom") { |_ctx, **| }
98
+ spec = @command_registry.fetch("my.cmd")
99
+ assert_equal :user, spec.source
100
+ assert_equal "custom", spec.desc
101
+ end
102
+
103
+ def test_command_without_block_raises
104
+ assert_raises(NoMethodError, ArgumentError) { @dsl.command("my.cmd") }
105
+ end
106
+
107
+ def test_nmap_with_filetype
108
+ dsl = RuVim::ConfigDSL.new(
109
+ command_registry: @command_registry,
110
+ ex_registry: @ex_registry,
111
+ keymaps: @keymaps,
112
+ command_host: RuVim::GlobalCommands.instance,
113
+ filetype: "ruby"
114
+ )
115
+ dsl.nmap("K", desc: "ft test") { |_ctx, **| }
116
+ editor = fresh_editor
117
+ editor.current_buffer.options["filetype"] = "ruby"
118
+ match = @keymaps.resolve_with_context(:normal, ["K"], editor: editor)
119
+ assert_equal :match, match.status
120
+ end
121
+
122
+ def test_set_option_requires_editor
123
+ assert_raises(NoMethodError, ArgumentError) { @dsl.set("number") }
124
+ end
125
+
126
+ def test_set_boolean_option
127
+ editor = fresh_editor
128
+ dsl = RuVim::ConfigDSL.new(
129
+ command_registry: @command_registry,
130
+ ex_registry: @ex_registry,
131
+ keymaps: @keymaps,
132
+ command_host: RuVim::GlobalCommands.instance,
133
+ editor: editor
134
+ )
135
+ dsl.set("number")
136
+ assert editor.get_option("number")
137
+ end
138
+
139
+ def test_set_no_prefix_disables_option
140
+ editor = fresh_editor
141
+ dsl = RuVim::ConfigDSL.new(
142
+ command_registry: @command_registry,
143
+ ex_registry: @ex_registry,
144
+ keymaps: @keymaps,
145
+ command_host: RuVim::GlobalCommands.instance,
146
+ editor: editor
147
+ )
148
+ dsl.set("number")
149
+ dsl.set("nonumber")
150
+ refute editor.get_option("number")
151
+ end
152
+
153
+ def test_set_with_value
154
+ editor = fresh_editor
155
+ dsl = RuVim::ConfigDSL.new(
156
+ command_registry: @command_registry,
157
+ ex_registry: @ex_registry,
158
+ keymaps: @keymaps,
159
+ command_host: RuVim::GlobalCommands.instance,
160
+ editor: editor
161
+ )
162
+ dsl.set("tabstop=4")
163
+ assert_equal 4, editor.get_option("tabstop")
164
+ end
78
165
  end
@@ -15,4 +15,45 @@ class DisplayWidthTest < Minitest::Test
15
15
  ENV["RUVIM_AMBIGUOUS_WIDTH"] = prev
16
16
  end
17
17
  end
18
+
19
+ def test_zero_codepoint_returns_zero
20
+ assert_equal 0, RuVim::DisplayWidth.uncached_codepoint_width(0)
21
+ end
22
+
23
+ def test_expand_tabs_basic
24
+ assert_equal " hello", RuVim::DisplayWidth.expand_tabs("\thello", tabstop: 2)
25
+ end
26
+
27
+ def test_expand_tabs_mid_column
28
+ assert_equal "a hello", RuVim::DisplayWidth.expand_tabs("a\thello", tabstop: 2)
29
+ end
30
+
31
+ def test_expand_tabs_with_tabstop_4
32
+ assert_equal " hello", RuVim::DisplayWidth.expand_tabs("\thello", tabstop: 4)
33
+ end
34
+
35
+ def test_expand_tabs_preserves_non_tab_chars
36
+ assert_equal "hello", RuVim::DisplayWidth.expand_tabs("hello", tabstop: 2)
37
+ end
38
+
39
+ def test_expand_tabs_with_start_col
40
+ result = RuVim::DisplayWidth.expand_tabs("\tx", tabstop: 4, start_col: 1)
41
+ assert_equal " x", result
42
+ end
43
+
44
+ def test_wide_codepoint_cjk
45
+ assert_equal 2, RuVim::DisplayWidth.cell_width("漢")
46
+ end
47
+
48
+ def test_wide_codepoint_fullwidth_form
49
+ assert_equal 2, RuVim::DisplayWidth.cell_width("A")
50
+ end
51
+
52
+ def test_combining_mark_returns_zero
53
+ assert_equal 0, RuVim::DisplayWidth.cell_width("\u0300")
54
+ end
55
+
56
+ def test_zero_width_joiner_returns_zero
57
+ assert_equal 0, RuVim::DisplayWidth.cell_width("\u200D")
58
+ end
18
59
  end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+ require "ruvim/file_watcher"
5
+ require "tempfile"
6
+
7
+ class FileWatcherTest < Minitest::Test
8
+ def setup
9
+ @tmpfile = Tempfile.new("file_watcher_test")
10
+ @tmpfile.write("initial\n")
11
+ @tmpfile.flush
12
+ @path = @tmpfile.path
13
+ end
14
+
15
+ def teardown
16
+ @watcher&.stop
17
+ @tmpfile&.close!
18
+ end
19
+
20
+ def test_polling_watcher_detects_append
21
+ received = Queue.new
22
+ @watcher = RuVim::FileWatcher::PollingWatcher.new(@path) do |type, data|
23
+ received << [type, data]
24
+ end
25
+ @watcher.start
26
+
27
+ File.open(@path, "a") { |f| f.write("appended\n") }
28
+
29
+ event = nil
30
+ assert_eventually(timeout: 2) { event = received.pop(true) rescue nil; !event.nil? }
31
+ assert_equal :data, event[0]
32
+ assert_includes event[1], "appended"
33
+ ensure
34
+ @watcher&.stop
35
+ end
36
+
37
+ def test_polling_watcher_stop
38
+ @watcher = RuVim::FileWatcher::PollingWatcher.new(@path) { |_, _| }
39
+ @watcher.start
40
+ assert @watcher.alive?
41
+ @watcher.stop
42
+ refute @watcher.alive?
43
+ end
44
+
45
+ def test_polling_watcher_detects_truncation
46
+ received = Queue.new
47
+ @watcher = RuVim::FileWatcher::PollingWatcher.new(@path) do |type, data|
48
+ received << [type, data]
49
+ end
50
+ @watcher.start
51
+
52
+ # Append first so offset advances
53
+ File.open(@path, "a") { |f| f.write("extra\n") }
54
+ assert_eventually(timeout: 2) { received.pop(true) rescue nil }
55
+
56
+ # Truncate the file
57
+ File.write(@path, "")
58
+
59
+ event = nil
60
+ assert_eventually(timeout: 3) { event = received.pop(true) rescue nil; event&.first == :truncated }
61
+ assert_equal :truncated, event[0]
62
+ assert_nil event[1]
63
+ ensure
64
+ @watcher&.stop
65
+ end
66
+
67
+ def test_polling_watcher_detects_deletion
68
+ received = Queue.new
69
+ @watcher = RuVim::FileWatcher::PollingWatcher.new(@path) do |type, data|
70
+ received << [type, data]
71
+ end
72
+ @watcher.start
73
+
74
+ File.delete(@path)
75
+
76
+ event = nil
77
+ assert_eventually(timeout: 3) { event = received.pop(true) rescue nil; event&.first == :deleted }
78
+ assert_equal :deleted, event[0]
79
+ assert_nil event[1]
80
+ ensure
81
+ @watcher&.stop
82
+ end
83
+
84
+ def test_polling_watcher_waits_for_missing_file
85
+ missing_path = File.join(Dir.pwd, "follow_missing_test_#{$$}.log")
86
+ File.delete(missing_path) if File.exist?(missing_path)
87
+
88
+ received = Queue.new
89
+ watcher = RuVim::FileWatcher::PollingWatcher.new(missing_path) do |type, data|
90
+ received << [type, data]
91
+ end
92
+ watcher.start
93
+
94
+ sleep 0.2
95
+ assert watcher.alive?
96
+
97
+ File.write(missing_path, "hello\n")
98
+
99
+ event = nil
100
+ assert_eventually(timeout: 3) { event = received.pop(true) rescue nil; !event.nil? }
101
+ assert_equal :data, event[0]
102
+ assert_includes event[1], "hello"
103
+ ensure
104
+ watcher&.stop
105
+ File.delete(missing_path) if File.exist?(missing_path)
106
+ end
107
+
108
+ def test_inotify_watcher_detects_append
109
+ skip "inotify not available" unless RuVim::FileWatcher::InotifyWatcher.available?
110
+
111
+ received = Queue.new
112
+ @watcher = RuVim::FileWatcher::InotifyWatcher.new(@path) do |type, data|
113
+ received << [type, data]
114
+ end
115
+ @watcher.start
116
+
117
+ File.open(@path, "a") { |f| f.write("inotify appended\n") }
118
+
119
+ event = nil
120
+ assert_eventually(timeout: 2) { event = received.pop(true) rescue nil; !event.nil? }
121
+ assert_equal :data, event[0]
122
+ assert_includes event[1], "inotify appended"
123
+ ensure
124
+ @watcher&.stop
125
+ end
126
+
127
+ def test_inotify_watcher_stop
128
+ skip "inotify not available" unless RuVim::FileWatcher::InotifyWatcher.available?
129
+
130
+ @watcher = RuVim::FileWatcher::InotifyWatcher.new(@path) { |_, _| }
131
+ @watcher.start
132
+ assert @watcher.alive?
133
+ @watcher.stop
134
+ refute @watcher.alive?
135
+ end
136
+
137
+ def test_inotify_watcher_detects_truncation
138
+ skip "inotify not available" unless RuVim::FileWatcher::InotifyWatcher.available?
139
+
140
+ received = Queue.new
141
+ @watcher = RuVim::FileWatcher::InotifyWatcher.new(@path) do |type, data|
142
+ received << [type, data]
143
+ end
144
+ @watcher.start
145
+
146
+ File.open(@path, "a") { |f| f.write("extra\n") }
147
+ assert_eventually(timeout: 2) { received.pop(true) rescue nil }
148
+
149
+ File.write(@path, "")
150
+
151
+ event = nil
152
+ assert_eventually(timeout: 3) { event = received.pop(true) rescue nil; event&.first == :truncated }
153
+ assert_equal :truncated, event[0]
154
+ ensure
155
+ @watcher&.stop
156
+ end
157
+
158
+ def test_create_prefers_inotify
159
+ watcher = RuVim::FileWatcher.create(@path) { |_, _| }
160
+ if RuVim::FileWatcher::InotifyWatcher.available?
161
+ assert_kind_of RuVim::FileWatcher::InotifyWatcher, watcher
162
+ else
163
+ assert_kind_of RuVim::FileWatcher::PollingWatcher, watcher
164
+ end
165
+ ensure
166
+ watcher&.stop
167
+ end
168
+
169
+ def test_create_falls_back_to_polling_for_missing_file
170
+ missing_path = File.join(Dir.pwd, "follow_create_test_#{$$}.log")
171
+ File.delete(missing_path) if File.exist?(missing_path)
172
+
173
+ watcher = RuVim::FileWatcher.create(missing_path) { |_, _| }
174
+ assert_kind_of RuVim::FileWatcher::PollingWatcher, watcher
175
+ ensure
176
+ watcher&.stop
177
+ File.delete(missing_path) if File.exist?(missing_path)
178
+ end
179
+
180
+ def test_polling_backoff_resets_on_change
181
+ @watcher = RuVim::FileWatcher::PollingWatcher.new(@path) { |_, _| }
182
+ assert_equal RuVim::FileWatcher::PollingWatcher::MIN_INTERVAL, @watcher.current_interval
183
+ end
184
+
185
+ private
186
+
187
+ def assert_eventually(timeout: 2, interval: 0.05)
188
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
189
+ loop do
190
+ return if yield
191
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
192
+ flunk "Timed out waiting for condition"
193
+ end
194
+ sleep interval
195
+ end
196
+ end
197
+ end