ruvim 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +4 -0
  3. data/AGENTS.md +96 -0
  4. data/CLAUDE.md +1 -0
  5. data/README.md +15 -1
  6. data/docs/binding.md +39 -0
  7. data/docs/command.md +163 -4
  8. data/docs/config.md +12 -4
  9. data/docs/done.md +21 -0
  10. data/docs/spec.md +214 -18
  11. data/docs/todo.md +1 -5
  12. data/docs/tutorial.md +24 -0
  13. data/docs/vim_diff.md +105 -173
  14. data/lib/ruvim/app.rb +1165 -70
  15. data/lib/ruvim/buffer.rb +47 -1
  16. data/lib/ruvim/cli.rb +18 -3
  17. data/lib/ruvim/clipboard.rb +2 -0
  18. data/lib/ruvim/command_invocation.rb +3 -1
  19. data/lib/ruvim/command_line.rb +2 -0
  20. data/lib/ruvim/command_registry.rb +2 -0
  21. data/lib/ruvim/config_dsl.rb +2 -0
  22. data/lib/ruvim/config_loader.rb +2 -0
  23. data/lib/ruvim/context.rb +2 -0
  24. data/lib/ruvim/dispatcher.rb +143 -13
  25. data/lib/ruvim/display_width.rb +3 -0
  26. data/lib/ruvim/editor.rb +466 -71
  27. data/lib/ruvim/ex_command_registry.rb +2 -0
  28. data/lib/ruvim/file_watcher.rb +243 -0
  29. data/lib/ruvim/git/blame.rb +245 -0
  30. data/lib/ruvim/git/branch.rb +97 -0
  31. data/lib/ruvim/git/commit.rb +102 -0
  32. data/lib/ruvim/git/diff.rb +129 -0
  33. data/lib/ruvim/git/handler.rb +84 -0
  34. data/lib/ruvim/git/log.rb +41 -0
  35. data/lib/ruvim/git/status.rb +103 -0
  36. data/lib/ruvim/global_commands.rb +1066 -105
  37. data/lib/ruvim/highlighter.rb +19 -22
  38. data/lib/ruvim/input.rb +40 -28
  39. data/lib/ruvim/keymap_manager.rb +83 -0
  40. data/lib/ruvim/keyword_chars.rb +2 -0
  41. data/lib/ruvim/lang/base.rb +25 -0
  42. data/lib/ruvim/lang/csv.rb +18 -0
  43. data/lib/ruvim/lang/diff.rb +41 -0
  44. data/lib/ruvim/lang/json.rb +52 -0
  45. data/lib/ruvim/lang/markdown.rb +170 -0
  46. data/lib/ruvim/lang/ruby.rb +236 -0
  47. data/lib/ruvim/lang/scheme.rb +44 -0
  48. data/lib/ruvim/lang/tsv.rb +19 -0
  49. data/lib/ruvim/rich_view/json_renderer.rb +131 -0
  50. data/lib/ruvim/rich_view/jsonl_renderer.rb +57 -0
  51. data/lib/ruvim/rich_view/markdown_renderer.rb +248 -0
  52. data/lib/ruvim/rich_view/table_renderer.rb +176 -0
  53. data/lib/ruvim/rich_view.rb +109 -0
  54. data/lib/ruvim/screen.rb +503 -109
  55. data/lib/ruvim/terminal.rb +18 -1
  56. data/lib/ruvim/text_metrics.rb +2 -0
  57. data/lib/ruvim/version.rb +1 -1
  58. data/lib/ruvim/window.rb +2 -0
  59. data/lib/ruvim.rb +24 -0
  60. data/test/app_completion_test.rb +98 -0
  61. data/test/app_dot_repeat_test.rb +13 -0
  62. data/test/app_motion_test.rb +13 -0
  63. data/test/app_scenario_test.rb +898 -1
  64. data/test/app_startup_test.rb +187 -0
  65. data/test/arglist_test.rb +113 -0
  66. data/test/buffer_test.rb +49 -30
  67. data/test/cli_test.rb +14 -0
  68. data/test/clipboard_test.rb +67 -0
  69. data/test/command_line_test.rb +118 -0
  70. data/test/config_dsl_test.rb +87 -0
  71. data/test/dispatcher_test.rb +322 -0
  72. data/test/display_width_test.rb +41 -0
  73. data/test/editor_register_test.rb +23 -0
  74. data/test/file_watcher_test.rb +197 -0
  75. data/test/follow_test.rb +199 -0
  76. data/test/git_blame_test.rb +713 -0
  77. data/test/highlighter_test.rb +165 -0
  78. data/test/indent_test.rb +287 -0
  79. data/test/input_screen_integration_test.rb +40 -2
  80. data/test/markdown_renderer_test.rb +279 -0
  81. data/test/on_save_hook_test.rb +150 -0
  82. data/test/rich_view_test.rb +734 -0
  83. data/test/screen_test.rb +304 -0
  84. data/test/search_option_test.rb +19 -0
  85. data/test/test_helper.rb +9 -0
  86. metadata +49 -2
@@ -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
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+ require "tempfile"
5
+
6
+ class FollowTest < Minitest::Test
7
+ def create_follow_app
8
+ @tmpfile = Tempfile.new(["follow_test", ".txt"])
9
+ @tmpfile.write("line1\nline2\n")
10
+ @tmpfile.flush
11
+ @path = @tmpfile.path
12
+
13
+ @app = RuVim::App.new(path: @path, clean: true)
14
+ @editor = @app.instance_variable_get(:@editor)
15
+ @dispatcher = @app.instance_variable_get(:@dispatcher)
16
+ end
17
+
18
+ def cleanup_follow_app
19
+ return unless @app
20
+
21
+ watchers = @app.instance_variable_get(:@follow_watchers)
22
+ watchers.each_value { |w| w.stop rescue nil }
23
+ watchers.clear
24
+ @tmpfile&.close!
25
+ end
26
+
27
+ def test_follow_starts_on_file_buffer
28
+ create_follow_app
29
+ @dispatcher.dispatch_ex(@editor, "follow")
30
+ watchers = @app.instance_variable_get(:@follow_watchers)
31
+ buf = @editor.current_buffer
32
+
33
+ assert !@editor.message_error?, "Unexpected error: #{@editor.message}"
34
+ assert_equal :live, buf.stream_state
35
+ assert watchers.key?(buf.id)
36
+ assert_includes @editor.message.to_s, "[follow]"
37
+ ensure
38
+ cleanup_follow_app
39
+ end
40
+
41
+ def test_follow_stops_on_ctrl_c
42
+ create_follow_app
43
+ @dispatcher.dispatch_ex(@editor, "follow")
44
+ buf = @editor.current_buffer
45
+ assert_equal :live, buf.stream_state
46
+
47
+ @app.send(:handle_key, :ctrl_c)
48
+ assert_nil buf.stream_state
49
+ assert_includes @editor.message.to_s, "stopped"
50
+ ensure
51
+ cleanup_follow_app
52
+ end
53
+
54
+ def test_follow_toggle_stops
55
+ create_follow_app
56
+ @dispatcher.dispatch_ex(@editor, "follow")
57
+ buf = @editor.current_buffer
58
+ assert_equal :live, buf.stream_state
59
+
60
+ @dispatcher.dispatch_ex(@editor, "follow")
61
+ watchers = @app.instance_variable_get(:@follow_watchers)
62
+ assert_nil buf.stream_state
63
+ refute watchers.key?(buf.id)
64
+ assert_includes @editor.message.to_s, "stopped"
65
+ ensure
66
+ cleanup_follow_app
67
+ end
68
+
69
+ def test_follow_stop_removes_sentinel_empty_line
70
+ create_follow_app
71
+ buf = @editor.current_buffer
72
+ lines_before = buf.line_count
73
+
74
+ @dispatcher.dispatch_ex(@editor, "follow")
75
+ @dispatcher.dispatch_ex(@editor, "follow")
76
+
77
+ assert_equal lines_before, buf.line_count, "Sentinel empty line should be removed on stop"
78
+ refute_equal "", buf.lines.last, "Last line should not be empty sentinel"
79
+ ensure
80
+ cleanup_follow_app
81
+ end
82
+
83
+ def test_follow_makes_buffer_not_modifiable
84
+ create_follow_app
85
+ buf = @editor.current_buffer
86
+ assert buf.modifiable?
87
+
88
+ @dispatcher.dispatch_ex(@editor, "follow")
89
+ refute buf.modifiable?, "Buffer should not be modifiable during follow"
90
+
91
+ @dispatcher.dispatch_ex(@editor, "follow")
92
+ assert buf.modifiable?, "Buffer should be modifiable after follow stops"
93
+ ensure
94
+ cleanup_follow_app
95
+ end
96
+
97
+ def test_follow_error_on_modified_buffer
98
+ create_follow_app
99
+ buf = @editor.current_buffer
100
+ buf.modified = true
101
+
102
+ @dispatcher.dispatch_ex(@editor, "follow")
103
+ assert_includes @editor.message.to_s, "unsaved changes"
104
+ assert_nil buf.stream_state
105
+ ensure
106
+ cleanup_follow_app
107
+ end
108
+
109
+ def test_follow_error_on_no_path_buffer
110
+ @app = RuVim::App.new(clean: true)
111
+ @editor = @app.instance_variable_get(:@editor)
112
+ @dispatcher = @app.instance_variable_get(:@dispatcher)
113
+ @editor.materialize_intro_buffer!
114
+ buf = @editor.current_buffer
115
+ buf.instance_variable_set(:@path, nil)
116
+
117
+ @dispatcher.dispatch_ex(@editor, "follow")
118
+ assert_includes @editor.message.to_s, "No file"
119
+ end
120
+
121
+ def test_follow_appends_data_from_file
122
+ create_follow_app
123
+ win = @editor.current_window
124
+ buf = @editor.current_buffer
125
+ win.cursor_y = buf.line_count - 1
126
+
127
+ @dispatcher.dispatch_ex(@editor, "follow")
128
+
129
+ File.open(@path, "a") { |f| f.write("line3\nline4\n") }
130
+
131
+ assert_eventually(timeout: 3) do
132
+ @app.send(:drain_stream_events!)
133
+ buf.line_count > 3
134
+ end
135
+
136
+ # line2 should NOT be joined with line3
137
+ assert_includes buf.lines, "line2"
138
+ assert_includes buf.lines, "line3"
139
+ assert_includes buf.lines, "line4"
140
+ assert_equal buf.line_count - 1, win.cursor_y
141
+ ensure
142
+ cleanup_follow_app
143
+ end
144
+
145
+ def test_follow_no_scroll_when_cursor_not_at_end
146
+ create_follow_app
147
+ buf = @editor.current_buffer
148
+ win = @editor.current_window
149
+ win.cursor_y = 0
150
+
151
+ @dispatcher.dispatch_ex(@editor, "follow")
152
+
153
+ File.open(@path, "a") { |f| f.write("line3\n") }
154
+
155
+ assert_eventually(timeout: 3) do
156
+ @app.send(:drain_stream_events!)
157
+ buf.line_count > 2
158
+ end
159
+
160
+ assert_equal 0, win.cursor_y
161
+ ensure
162
+ cleanup_follow_app
163
+ end
164
+
165
+ def test_startup_follow_applies_to_all_buffers
166
+ tmp1 = Tempfile.new(["follow_multi1", ".txt"])
167
+ tmp1.write("aaa\n"); tmp1.flush
168
+ tmp2 = Tempfile.new(["follow_multi2", ".txt"])
169
+ tmp2.write("bbb\n"); tmp2.flush
170
+
171
+ app = RuVim::App.new(paths: [tmp1.path, tmp2.path], follow: true, clean: true)
172
+ editor = app.instance_variable_get(:@editor)
173
+ watchers = app.instance_variable_get(:@follow_watchers)
174
+
175
+ bufs = editor.buffers.values.select(&:file_buffer?)
176
+ assert_equal 2, bufs.size
177
+ bufs.each do |buf|
178
+ assert_equal :live, buf.stream_state, "#{buf.display_name} should be in follow mode"
179
+ assert watchers.key?(buf.id), "#{buf.display_name} should have a watcher"
180
+ end
181
+ ensure
182
+ watchers&.each_value { |w| w.stop rescue nil }
183
+ tmp1&.close!
184
+ tmp2&.close!
185
+ end
186
+
187
+ private
188
+
189
+ def assert_eventually(timeout: 2, interval: 0.05)
190
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
191
+ loop do
192
+ return if yield
193
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
194
+ flunk "Timed out waiting for condition"
195
+ end
196
+ sleep interval
197
+ end
198
+ end
199
+ end