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
@@ -56,7 +56,7 @@ class InputScreenIntegrationTest < Minitest::Test
56
56
  app.instance_variable_set(:@screen, screen)
57
57
 
58
58
  stdin = FakeTTY.new("\e[6~")
59
- input = RuVim::Input.new(stdin: stdin)
59
+ input = RuVim::Input.new(stdin)
60
60
 
61
61
  with_fake_select do
62
62
  key = input.read_key(timeout: 0.2)
@@ -72,11 +72,49 @@ class InputScreenIntegrationTest < Minitest::Test
72
72
 
73
73
  def test_input_keeps_repeated_arrow_sequences_separate
74
74
  stdin = FakeTTY.new("\e[A\e[A")
75
- input = RuVim::Input.new(stdin: stdin)
75
+ input = RuVim::Input.new(stdin)
76
76
 
77
77
  with_fake_select do
78
78
  assert_equal :up, input.read_key(timeout: 0.2)
79
79
  assert_equal :up, input.read_key(timeout: 0.2)
80
80
  end
81
81
  end
82
+
83
+ def test_input_reads_ctrl_z
84
+ stdin = FakeTTY.new("\u001a")
85
+ input = RuVim::Input.new(stdin)
86
+
87
+ with_fake_select do
88
+ assert_equal :ctrl_z, input.read_key(timeout: 0.2)
89
+ end
90
+ end
91
+
92
+ def test_input_shift_arrow_sequences
93
+ {
94
+ "\e[1;2A" => :shift_up,
95
+ "\e[1;2B" => :shift_down,
96
+ "\e[1;2C" => :shift_right,
97
+ "\e[1;2D" => :shift_left
98
+ }.each do |seq, expected|
99
+ stdin = FakeTTY.new(seq)
100
+ input = RuVim::Input.new(stdin)
101
+
102
+ with_fake_select do
103
+ assert_equal expected, input.read_key(timeout: 0.2), "Expected #{expected} for sequence #{seq.inspect}"
104
+ end
105
+ end
106
+ end
107
+
108
+ def test_has_pending_input_returns_true_when_data_available
109
+ stdin = FakeTTY.new("abc")
110
+ input = RuVim::Input.new(stdin)
111
+
112
+ with_fake_select do
113
+ assert input.has_pending_input?
114
+ input.read_key(timeout: 0)
115
+ input.read_key(timeout: 0)
116
+ input.read_key(timeout: 0)
117
+ refute input.has_pending_input?
118
+ end
119
+ end
82
120
  end
@@ -0,0 +1,279 @@
1
+ require_relative "test_helper"
2
+
3
+ class MarkdownRendererTest < Minitest::Test
4
+ def renderer
5
+ RuVim::RichView::MarkdownRenderer
6
+ end
7
+
8
+ # --- Registration ---
9
+
10
+ def test_renderer_registered_for_markdown
11
+ assert_equal renderer, RuVim::RichView.renderer_for("markdown")
12
+ end
13
+
14
+ def test_delimiter_for_markdown
15
+ assert_nil renderer.delimiter_for("markdown")
16
+ end
17
+
18
+ # --- Headings ---
19
+
20
+ def test_heading_h1_bold
21
+ lines = ["# Hello"]
22
+ result = renderer.render_visible(lines, delimiter: nil)
23
+ assert_equal 1, result.length
24
+ assert_match(/\e\[1[;m]/, result[0]) # bold (standalone or combined)
25
+ assert_includes result[0], "# Hello"
26
+ end
27
+
28
+ def test_heading_h2
29
+ lines = ["## Section"]
30
+ result = renderer.render_visible(lines, delimiter: nil)
31
+ assert_includes result[0], "## Section"
32
+ assert_match(/\e\[1[;m]/, result[0]) # bold
33
+ end
34
+
35
+ def test_heading_h3_to_h6
36
+ (3..6).each do |level|
37
+ hashes = "#" * level
38
+ lines = ["#{hashes} Title"]
39
+ result = renderer.render_visible(lines, delimiter: nil)
40
+ assert_includes result[0], "#{hashes} Title", "H#{level} text should be preserved"
41
+ assert_includes result[0], "\e[", "H#{level} should have ANSI styling"
42
+ end
43
+ end
44
+
45
+ # --- Inline elements ---
46
+
47
+ def test_inline_bold
48
+ lines = ["hello **bold** world"]
49
+ result = renderer.render_visible(lines, delimiter: nil)
50
+ assert_includes result[0], "\e[1m" # bold on
51
+ assert_includes result[0], "\e[22m" # bold off
52
+ assert_includes result[0], "**bold**"
53
+ end
54
+
55
+ def test_inline_italic
56
+ lines = ["hello *italic* world"]
57
+ result = renderer.render_visible(lines, delimiter: nil)
58
+ assert_includes result[0], "\e[3m" # italic on
59
+ assert_includes result[0], "\e[23m" # italic off
60
+ assert_includes result[0], "*italic*"
61
+ end
62
+
63
+ def test_inline_code
64
+ lines = ["use `foo()` here"]
65
+ result = renderer.render_visible(lines, delimiter: nil)
66
+ assert_includes result[0], "\e[33m" # yellow
67
+ assert_includes result[0], "`foo()`"
68
+ end
69
+
70
+ def test_inline_link
71
+ lines = ["click [here](http://example.com) now"]
72
+ result = renderer.render_visible(lines, delimiter: nil)
73
+ assert_includes result[0], "\e[4m" # underline for text
74
+ assert_includes result[0], "here"
75
+ assert_includes result[0], "http://example.com"
76
+ end
77
+
78
+ def test_checkbox_unchecked
79
+ lines = ["- [ ] todo item"]
80
+ result = renderer.render_visible(lines, delimiter: nil)
81
+ assert_includes result[0], "\e[90m" # dim
82
+ assert_includes result[0], "[ ]"
83
+ end
84
+
85
+ def test_checkbox_checked
86
+ lines = ["- [x] done item"]
87
+ result = renderer.render_visible(lines, delimiter: nil)
88
+ assert_includes result[0], "\e[32m" # green
89
+ assert_includes result[0], "[x]"
90
+ end
91
+
92
+ # --- Code blocks ---
93
+
94
+ def test_code_fence_styling
95
+ lines = ["```ruby", "puts 'hi'", "```"]
96
+ result = renderer.render_visible(lines, delimiter: nil)
97
+ # Fence lines should be dim
98
+ assert_includes result[0], "\e[90m"
99
+ # Content should be warm-colored
100
+ assert_includes result[1], "\e[38;5;223m"
101
+ # Closing fence should be dim
102
+ assert_includes result[2], "\e[90m"
103
+ end
104
+
105
+ def test_code_fence_tilde
106
+ lines = ["~~~", "code line", "~~~"]
107
+ result = renderer.render_visible(lines, delimiter: nil)
108
+ assert_includes result[0], "\e[90m"
109
+ assert_includes result[1], "\e[38;5;223m"
110
+ end
111
+
112
+ def test_code_block_context_from_pre_context
113
+ # Simulate rendering in the middle of a code block
114
+ # pre_context_lines should carry the open fence state
115
+ pre_context = ["```ruby", "line1"]
116
+ lines = ["line2", "```"]
117
+ result = renderer.render_visible(lines, delimiter: nil, context: { pre_context_lines: pre_context })
118
+ # line2 should be inside code block (warm color)
119
+ assert_includes result[0], "\e[38;5;223m"
120
+ # closing fence
121
+ assert_includes result[1], "\e[90m"
122
+ end
123
+
124
+ def test_needs_pre_context
125
+ assert renderer.needs_pre_context?
126
+ end
127
+
128
+ # --- HR ---
129
+
130
+ def test_hr_dashes
131
+ lines = ["---"]
132
+ result = renderer.render_visible(lines, delimiter: nil)
133
+ assert_includes result[0], "\u2500" # box drawing horizontal
134
+ end
135
+
136
+ def test_hr_asterisks
137
+ lines = ["***"]
138
+ result = renderer.render_visible(lines, delimiter: nil)
139
+ assert_includes result[0], "\u2500"
140
+ end
141
+
142
+ def test_hr_underscores
143
+ lines = ["___"]
144
+ result = renderer.render_visible(lines, delimiter: nil)
145
+ assert_includes result[0], "\u2500"
146
+ end
147
+
148
+ # --- Block quotes ---
149
+
150
+ def test_block_quote
151
+ lines = ["> quoted text"]
152
+ result = renderer.render_visible(lines, delimiter: nil)
153
+ assert_includes result[0], "\e[36m" # cyan
154
+ assert_includes result[0], "> quoted text"
155
+ end
156
+
157
+ # --- Tables ---
158
+
159
+ def test_table_basic
160
+ lines = [
161
+ "| Name | Age |",
162
+ "| ----- | --- |",
163
+ "| Alice | 30 |"
164
+ ]
165
+ result = renderer.render_visible(lines, delimiter: nil)
166
+ assert_equal 3, result.length
167
+ # Data rows should use box-drawing vertical bar
168
+ assert_includes result[0], "\u2502" # │
169
+ assert_includes result[2], "\u2502"
170
+ # Separator row should use box-drawing
171
+ assert_includes result[1], "\u2500" # ─
172
+ assert_includes result[1], "\u253c" # ┼
173
+ end
174
+
175
+ def test_table_column_alignment
176
+ lines = [
177
+ "| Short | Long column |",
178
+ "| ----- | ----------- |",
179
+ "| A | B |"
180
+ ]
181
+ result = renderer.render_visible(lines, delimiter: nil)
182
+ # Both data rows should have same display width
183
+ w0 = display_width_without_ansi(result[0])
184
+ w2 = display_width_without_ansi(result[2])
185
+ assert_equal w0, w2
186
+ end
187
+
188
+ # --- cursor_display_col ---
189
+
190
+ def test_cursor_display_col_non_table_line
191
+ # For non-table lines, should return screen_col_for_char_index
192
+ line = "hello world"
193
+ col = renderer.cursor_display_col(line, 5, visible_lines: [line], delimiter: nil)
194
+ expected = RuVim::TextMetrics.screen_col_for_char_index(line, 5)
195
+ assert_equal expected, col
196
+ end
197
+
198
+ def test_cursor_display_col_table_line
199
+ lines = [
200
+ "| Name | Age |",
201
+ "| ----- | --- |",
202
+ "| Alice | 30 |"
203
+ ]
204
+ # Cursor at start of "Alice" (index within raw line)
205
+ raw_line = lines[2]
206
+ col = renderer.cursor_display_col(raw_line, 2, visible_lines: lines, delimiter: nil)
207
+ # Should be > 0 (after the leading │ and padding)
208
+ assert col >= 0
209
+ end
210
+
211
+ # --- ANSI support in render_rich_view_line_sc ---
212
+
213
+ def test_render_rich_view_line_sc_with_ansi
214
+ screen = create_test_screen
215
+ # Line with ANSI bold: "\e[1m" is 4 chars but 0 display width
216
+ text = "\e[1mhello\e[m world"
217
+ result = screen.send(:render_rich_view_line_sc, text, width: 20, skip_sc: 0)
218
+ # Should contain the ANSI sequences and the text
219
+ assert_includes result, "\e[1m"
220
+ assert_includes result, "hello"
221
+ assert_includes result, "world"
222
+ # Should end with reset
223
+ assert result.end_with?("\e[m") || result.include?("\e[m")
224
+ end
225
+
226
+ def test_render_rich_view_line_sc_ansi_skip
227
+ screen = create_test_screen
228
+ # ANSI at start, skip some display columns
229
+ text = "\e[1mhello\e[m world"
230
+ result = screen.send(:render_rich_view_line_sc, text, width: 5, skip_sc: 3)
231
+ # Should show "lo wo" or similar (skipping 3 display cols of "hello")
232
+ assert_includes result, "lo"
233
+ end
234
+
235
+ # --- Integration test ---
236
+
237
+ def test_markdown_rich_mode_integration
238
+ editor = fresh_editor
239
+ buf = editor.current_buffer
240
+ buf.replace_all_lines!(["# Title", "", "Some **bold** text"])
241
+ buf.options["filetype"] = "markdown"
242
+
243
+ RuVim::RichView.open!(editor, format: "markdown")
244
+ assert_equal :rich, editor.mode
245
+ state = editor.rich_state
246
+ assert_equal "markdown", state[:format]
247
+
248
+ # Render visible lines
249
+ lines = (0...buf.line_count).map { |i| buf.line_at(i) }
250
+ rendered = RuVim::RichView.render_visible_lines(editor, lines)
251
+ assert_equal 3, rendered.length
252
+ # Heading should have styling
253
+ assert_includes rendered[0], "\e["
254
+
255
+ RuVim::RichView.close!(editor)
256
+ assert_equal :normal, editor.mode
257
+ end
258
+
259
+ def test_detect_format_markdown
260
+ editor = fresh_editor
261
+ buf = editor.current_buffer
262
+ buf.options["filetype"] = "markdown"
263
+ assert_equal "markdown", RuVim::RichView.detect_format(buf)
264
+ end
265
+
266
+ private
267
+
268
+ def display_width_without_ansi(str)
269
+ # Strip ANSI escape sequences for width calculation
270
+ clean = str.gsub(/\e\[[0-9;]*m/, "")
271
+ RuVim::DisplayWidth.display_width(clean)
272
+ end
273
+
274
+ def create_test_screen
275
+ terminal = Object.new
276
+ def terminal.winsize; [24, 80]; end
277
+ RuVim::Screen.new(terminal: terminal)
278
+ end
279
+ end
@@ -0,0 +1,150 @@
1
+ require_relative "test_helper"
2
+ require "tmpdir"
3
+ require "fileutils"
4
+
5
+ class OnSaveHookTest < Minitest::Test
6
+ def test_base_on_save_is_noop
7
+ editor = fresh_editor
8
+ ctx = RuVim::Context.new(editor: editor)
9
+ # Should not raise
10
+ RuVim::Lang::Base.on_save(ctx, "/tmp/nonexistent.txt")
11
+ end
12
+
13
+ def test_ruby_on_save_with_valid_file
14
+ editor = fresh_editor
15
+ ctx = RuVim::Context.new(editor: editor)
16
+ Dir.mktmpdir do |dir|
17
+ path = File.join(dir, "valid.rb")
18
+ File.write(path, "puts 'hello'\n")
19
+ RuVim::Lang::Ruby.on_save(ctx, path)
20
+ # No error message should be set (echo from before should remain)
21
+ refute editor.message_error?
22
+ assert_empty editor.quickfix_items, "quickfix list should be empty for valid file"
23
+ end
24
+ end
25
+
26
+ def test_ruby_on_save_with_syntax_error
27
+ editor = fresh_editor
28
+ ctx = RuVim::Context.new(editor: editor)
29
+ Dir.mktmpdir do |dir|
30
+ path = File.join(dir, "bad.rb")
31
+ File.write(path, "def foo(\n")
32
+ RuVim::Lang::Ruby.on_save(ctx, path)
33
+ assert editor.message_error?
34
+ assert_match(/syntax error/i, editor.message)
35
+ refute_empty editor.quickfix_items, "quickfix list should be populated on syntax error"
36
+ item = editor.quickfix_items.first
37
+ assert_kind_of Integer, item[:row]
38
+ assert_match(/syntax error/i, item[:text])
39
+ end
40
+ end
41
+
42
+ def test_ruby_on_save_with_nil_path
43
+ editor = fresh_editor
44
+ ctx = RuVim::Context.new(editor: editor)
45
+ # Should not raise
46
+ RuVim::Lang::Ruby.on_save(ctx, nil)
47
+ refute editor.message_error?
48
+ end
49
+
50
+ def test_file_write_calls_on_save
51
+ app = RuVim::App.new(clean: true)
52
+ editor = app.instance_variable_get(:@editor)
53
+ editor.materialize_intro_buffer!
54
+
55
+ called = false
56
+ hook_module = Module.new do
57
+ define_method(:on_save) do |_ctx, _path|
58
+ called = true
59
+ end
60
+ module_function :on_save
61
+ end
62
+
63
+ Dir.mktmpdir do |dir|
64
+ path = File.join(dir, "test.rb")
65
+ editor.current_buffer.instance_variable_set(:@lang_module, hook_module)
66
+ editor.current_buffer.replace_all_lines!(["hello"])
67
+
68
+ # Execute :w command
69
+ keys = ":w #{path}\n".chars
70
+ keys.each { |k| app.send(:handle_key, k == "\n" ? :enter : k) }
71
+
72
+ assert called, "on_save hook should have been called"
73
+ end
74
+ end
75
+
76
+ def test_write_then_bracket_q_navigates_quickfix
77
+ app = RuVim::App.new(clean: true)
78
+ editor = app.instance_variable_get(:@editor)
79
+ editor.materialize_intro_buffer!
80
+
81
+ Dir.mktmpdir do |dir|
82
+ path = File.join(dir, "bad.rb")
83
+ # Two lines so error is on line 2 — verifiable jump target
84
+ File.write(path, "x = 1\ndef foo(\n")
85
+
86
+ # Open and write the file
87
+ ":e #{path}\n".chars.each { |k| app.send(:handle_key, k == "\n" ? :enter : k) }
88
+ ":w\n".chars.each { |k| app.send(:handle_key, k == "\n" ? :enter : k) }
89
+
90
+ refute_empty editor.quickfix_items, "quickfix should be populated after :w with syntax error"
91
+ assert_nil editor.quickfix_index, "quickfix index should be nil before navigation"
92
+
93
+ # Press ]q — should jump to first item (index 0)
94
+ app.send(:handle_key, "]")
95
+ app.send(:handle_key, "q")
96
+
97
+ assert_equal 0, editor.quickfix_index
98
+ first_item = editor.quickfix_items.first
99
+ assert_equal first_item[:row], editor.current_window.cursor_y,
100
+ "cursor should jump to first quickfix item on ]q"
101
+
102
+ assert_match(/qf/, editor.message)
103
+ end
104
+ end
105
+
106
+ def test_write_valid_clears_quickfix
107
+ app = RuVim::App.new(clean: true)
108
+ editor = app.instance_variable_get(:@editor)
109
+ editor.materialize_intro_buffer!
110
+
111
+ Dir.mktmpdir do |dir|
112
+ path = File.join(dir, "ok.rb")
113
+ File.write(path, "puts 'hi'\n")
114
+
115
+ ":e #{path}\n".chars.each { |k| app.send(:handle_key, k == "\n" ? :enter : k) }
116
+ # Set some dummy quickfix items first
117
+ editor.set_quickfix_list([{ buffer_id: editor.current_buffer.id, row: 0, col: 0, text: "dummy" }])
118
+ refute_empty editor.quickfix_items
119
+
120
+ ":w\n".chars.each { |k| app.send(:handle_key, k == "\n" ? :enter : k) }
121
+ assert_empty editor.quickfix_items, "quickfix should be cleared after :w with valid file"
122
+ end
123
+ end
124
+
125
+ def test_file_write_skips_on_save_when_onsavehook_disabled
126
+ app = RuVim::App.new(clean: true)
127
+ editor = app.instance_variable_get(:@editor)
128
+ editor.materialize_intro_buffer!
129
+
130
+ called = false
131
+ hook_module = Module.new do
132
+ define_method(:on_save) do |_ctx, _path|
133
+ called = true
134
+ end
135
+ module_function :on_save
136
+ end
137
+
138
+ Dir.mktmpdir do |dir|
139
+ path = File.join(dir, "test.rb")
140
+ editor.current_buffer.instance_variable_set(:@lang_module, hook_module)
141
+ editor.current_buffer.replace_all_lines!(["hello"])
142
+ editor.set_option("onsavehook", false)
143
+
144
+ keys = ":w #{path}\n".chars
145
+ keys.each { |k| app.send(:handle_key, k == "\n" ? :enter : k) }
146
+
147
+ refute called, "on_save hook should NOT have been called when onsavehook is disabled"
148
+ end
149
+ end
150
+ end