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,734 @@
1
+ require_relative "test_helper"
2
+
3
+ class RichViewTest < Minitest::Test
4
+ # --- Framework tests ---
5
+
6
+ def test_register_and_renderer_for
7
+ assert RuVim::RichView.renderer_for("tsv")
8
+ assert RuVim::RichView.renderer_for("csv")
9
+ assert_nil RuVim::RichView.renderer_for("unknown")
10
+ end
11
+
12
+ def test_registered_filetypes
13
+ fts = RuVim::RichView.registered_filetypes
14
+ assert_includes fts, "tsv"
15
+ assert_includes fts, "csv"
16
+ end
17
+
18
+ def test_detect_format_from_filetype
19
+ editor = fresh_editor
20
+ buf = editor.current_buffer
21
+ buf.options["filetype"] = "csv"
22
+ assert_equal "csv", RuVim::RichView.detect_format(buf)
23
+ end
24
+
25
+ def test_detect_format_auto_tsv
26
+ editor = fresh_editor
27
+ buf = editor.current_buffer
28
+ buf.replace_all_lines!(["a\tb\tc", "d\te\tf"])
29
+ assert_equal "tsv", RuVim::RichView.detect_format(buf)
30
+ end
31
+
32
+ def test_detect_format_auto_csv
33
+ editor = fresh_editor
34
+ buf = editor.current_buffer
35
+ buf.replace_all_lines!(["a,b,c", "d,e,f"])
36
+ assert_equal "csv", RuVim::RichView.detect_format(buf)
37
+ end
38
+
39
+ def test_detect_format_returns_nil_for_plain_text
40
+ editor = fresh_editor
41
+ buf = editor.current_buffer
42
+ buf.replace_all_lines!(["hello world", "foo bar"])
43
+ assert_nil RuVim::RichView.detect_format(buf)
44
+ end
45
+
46
+ def test_open_raises_when_format_unknown
47
+ editor = fresh_editor
48
+ buf = editor.current_buffer
49
+ buf.replace_all_lines!(["hello world"])
50
+ assert_raises(RuVim::CommandError) { RuVim::RichView.open!(editor) }
51
+ end
52
+
53
+ # --- Mode transition tests ---
54
+
55
+ def test_active_returns_false_for_normal_mode
56
+ editor = fresh_editor
57
+ refute RuVim::RichView.active?(editor)
58
+ end
59
+
60
+ def test_open_enters_rich_mode
61
+ editor = fresh_editor
62
+ buf = editor.current_buffer
63
+ buf.replace_all_lines!(["a\tb", "c\td"])
64
+ buf.options["filetype"] = "tsv"
65
+
66
+ RuVim::RichView.open!(editor, format: "tsv")
67
+ assert_equal :rich, editor.mode
68
+ assert RuVim::RichView.active?(editor)
69
+ end
70
+
71
+ def test_open_stays_on_same_buffer
72
+ editor = fresh_editor
73
+ buf = editor.current_buffer
74
+ buf.replace_all_lines!(["a\tb", "c\td"])
75
+ buf.options["filetype"] = "tsv"
76
+ original_id = buf.id
77
+
78
+ RuVim::RichView.open!(editor, format: "tsv")
79
+ assert_equal original_id, editor.current_buffer.id
80
+ end
81
+
82
+ def test_close_returns_to_normal_mode
83
+ editor = fresh_editor
84
+ buf = editor.current_buffer
85
+ buf.replace_all_lines!(["x\ty"])
86
+ buf.options["filetype"] = "tsv"
87
+
88
+ RuVim::RichView.open!(editor, format: "tsv")
89
+ assert_equal :rich, editor.mode
90
+
91
+ RuVim::RichView.close!(editor)
92
+ assert_equal :normal, editor.mode
93
+ refute RuVim::RichView.active?(editor)
94
+ end
95
+
96
+ def test_close_keeps_same_buffer
97
+ editor = fresh_editor
98
+ buf = editor.current_buffer
99
+ buf.replace_all_lines!(["x\ty"])
100
+ buf.options["filetype"] = "tsv"
101
+ original_id = buf.id
102
+
103
+ RuVim::RichView.open!(editor, format: "tsv")
104
+ RuVim::RichView.close!(editor)
105
+ assert_equal original_id, editor.current_buffer.id
106
+ end
107
+
108
+ def test_toggle_opens_and_closes
109
+ editor = fresh_editor
110
+ buf = editor.current_buffer
111
+ buf.replace_all_lines!(["a\tb"])
112
+ buf.options["filetype"] = "tsv"
113
+
114
+ RuVim::RichView.toggle!(editor)
115
+ assert RuVim::RichView.active?(editor)
116
+
117
+ RuVim::RichView.toggle!(editor)
118
+ refute RuVim::RichView.active?(editor)
119
+ end
120
+
121
+ def test_rich_state_stores_format_and_delimiter
122
+ editor = fresh_editor
123
+ buf = editor.current_buffer
124
+ buf.replace_all_lines!(["a\tb"])
125
+ buf.options["filetype"] = "tsv"
126
+
127
+ RuVim::RichView.open!(editor, format: "tsv")
128
+ state = editor.rich_state
129
+ assert_equal "tsv", state[:format]
130
+ assert_equal "\t", state[:delimiter]
131
+ end
132
+
133
+ def test_rich_state_csv
134
+ editor = fresh_editor
135
+ buf = editor.current_buffer
136
+ buf.replace_all_lines!(["a,b"])
137
+ buf.options["filetype"] = "csv"
138
+
139
+ RuVim::RichView.open!(editor, format: "csv")
140
+ state = editor.rich_state
141
+ assert_equal "csv", state[:format]
142
+ assert_equal ",", state[:delimiter]
143
+ end
144
+
145
+ def test_rich_state_nil_after_close
146
+ editor = fresh_editor
147
+ buf = editor.current_buffer
148
+ buf.replace_all_lines!(["a\tb"])
149
+ buf.options["filetype"] = "tsv"
150
+
151
+ RuVim::RichView.open!(editor, format: "tsv")
152
+ RuVim::RichView.close!(editor)
153
+ assert_nil editor.rich_state
154
+ end
155
+
156
+ def test_active_during_command_line_from_rich_mode
157
+ editor = fresh_editor
158
+ buf = editor.current_buffer
159
+ buf.replace_all_lines!(["a\tb"])
160
+ buf.options["filetype"] = "tsv"
161
+
162
+ RuVim::RichView.open!(editor, format: "tsv")
163
+ editor.enter_command_line_mode(":")
164
+ assert_equal :command_line, editor.mode
165
+ assert RuVim::RichView.active?(editor)
166
+ end
167
+
168
+ def test_cancel_command_line_returns_to_rich_mode
169
+ editor = fresh_editor
170
+ buf = editor.current_buffer
171
+ buf.replace_all_lines!(["a\tb"])
172
+ buf.options["filetype"] = "tsv"
173
+
174
+ RuVim::RichView.open!(editor, format: "tsv")
175
+ assert_equal :rich, editor.mode
176
+
177
+ editor.enter_command_line_mode(":")
178
+ assert_equal :command_line, editor.mode
179
+ assert editor.rich_state
180
+
181
+ editor.cancel_command_line
182
+ assert_equal :rich, editor.mode
183
+ assert editor.rich_state
184
+ end
185
+
186
+ def test_leave_command_line_returns_to_rich_mode
187
+ editor = fresh_editor
188
+ buf = editor.current_buffer
189
+ buf.replace_all_lines!(["a\tb"])
190
+ buf.options["filetype"] = "tsv"
191
+
192
+ RuVim::RichView.open!(editor, format: "tsv")
193
+ editor.enter_command_line_mode(":")
194
+ editor.leave_command_line
195
+ assert_equal :rich, editor.mode
196
+ assert editor.rich_state
197
+ end
198
+
199
+ def test_leave_command_line_returns_to_normal_without_rich_state
200
+ editor = fresh_editor
201
+ editor.enter_command_line_mode(":")
202
+ editor.leave_command_line
203
+ assert_equal :normal, editor.mode
204
+ assert_nil editor.rich_state
205
+ end
206
+
207
+ def test_enter_normal_mode_clears_rich_state
208
+ editor = fresh_editor
209
+ buf = editor.current_buffer
210
+ buf.replace_all_lines!(["a\tb"])
211
+ buf.options["filetype"] = "tsv"
212
+
213
+ RuVim::RichView.open!(editor, format: "tsv")
214
+ editor.enter_normal_mode
215
+ assert_nil editor.rich_state
216
+ assert_equal :normal, editor.mode
217
+ end
218
+
219
+ def test_render_visible_lines_integration
220
+ editor = fresh_editor
221
+ buf = editor.current_buffer
222
+ buf.replace_all_lines!(["a\tbb", "ccc\td"])
223
+ buf.options["filetype"] = "tsv"
224
+
225
+ RuVim::RichView.open!(editor, format: "tsv")
226
+ lines = [buf.line_at(0), buf.line_at(1)]
227
+ rendered = RuVim::RichView.render_visible_lines(editor, lines)
228
+ assert_equal 2, rendered.length
229
+ assert_includes rendered[0], " | "
230
+ end
231
+
232
+ def test_buffer_count_unchanged_after_open
233
+ editor = fresh_editor
234
+ buf = editor.current_buffer
235
+ buf.replace_all_lines!(["a\tb"])
236
+ buf.options["filetype"] = "tsv"
237
+ count_before = editor.buffers.length
238
+
239
+ RuVim::RichView.open!(editor, format: "tsv")
240
+ assert_equal count_before, editor.buffers.length
241
+ end
242
+
243
+ # --- TableRenderer tests ---
244
+
245
+ def test_basic_alignment
246
+ lines = ["a\tbb\tccc", "dddd\te\tf"]
247
+ result = RuVim::RichView::TableRenderer.render_visible(lines, delimiter: "\t")
248
+ assert_equal 2, result.length
249
+ # Both lines should have same total display width
250
+ w0 = RuVim::DisplayWidth.display_width(result[0])
251
+ w1 = RuVim::DisplayWidth.display_width(result[1])
252
+ assert_equal w0, w1
253
+ # Check separator presence
254
+ assert_includes result[0], " | "
255
+ end
256
+
257
+ def test_uneven_column_count
258
+ lines = ["a\tb", "c\td\te"]
259
+ result = RuVim::RichView::TableRenderer.render_visible(lines, delimiter: "\t")
260
+ assert_equal 2, result.length
261
+ # First row should have 3 columns padded (missing column filled)
262
+ parts0 = result[0].split(" | ")
263
+ parts1 = result[1].split(" | ")
264
+ assert_equal 3, parts0.length
265
+ assert_equal 3, parts1.length
266
+ end
267
+
268
+ def test_empty_cells
269
+ lines = ["a\t\tc", "\tb\t"]
270
+ result = RuVim::RichView::TableRenderer.render_visible(lines, delimiter: "\t")
271
+ assert_equal 2, result.length
272
+ assert_includes result[0], " | "
273
+ end
274
+
275
+ def test_single_column_passthrough
276
+ lines = ["abc", "def", "ghi"]
277
+ result = RuVim::RichView::TableRenderer.render_visible(lines, delimiter: "\t")
278
+ assert_equal lines, result
279
+ end
280
+
281
+ def test_cjk_characters_alignment
282
+ lines = ["名前\t年齢", "太郎\t25", "Alice\t30"]
283
+ result = RuVim::RichView::TableRenderer.render_visible(lines, delimiter: "\t")
284
+ assert_equal 3, result.length
285
+ # All rows should have the same display width
286
+ widths = result.map { |r| RuVim::DisplayWidth.display_width(r) }
287
+ assert_equal 1, widths.uniq.length, "All rows should have same display width: #{widths.inspect}"
288
+ end
289
+
290
+ def test_csv_basic
291
+ lines = ["a,b,c", "dd,e,ff"]
292
+ result = RuVim::RichView::TableRenderer.render_visible(lines, delimiter: ",")
293
+ assert_equal 2, result.length
294
+ assert_includes result[0], " | "
295
+ end
296
+
297
+ def test_csv_quoted_fields
298
+ lines = ['"hello, world",b,c', 'a,"say ""hi""",d']
299
+ result = RuVim::RichView::TableRenderer.render_visible(lines, delimiter: ",")
300
+ assert_equal 2, result.length
301
+ # First row first field should be unquoted
302
+ first_field = result[0].split(" | ").first.strip
303
+ assert_equal "hello, world", first_field
304
+ end
305
+
306
+ def test_csv_quoted_field_with_escaped_quotes
307
+ fields = RuVim::RichView::TableRenderer.parse_csv_fields('"say ""hi""",b')
308
+ assert_equal ['say "hi"', "b"], fields
309
+ end
310
+
311
+ def test_empty_lines
312
+ result = RuVim::RichView::TableRenderer.render_visible([], delimiter: "\t")
313
+ assert_equal [], result
314
+ end
315
+
316
+ # --- TableRenderer helper method tests ---
317
+
318
+ def test_compute_col_widths_basic
319
+ lines = ["a\tbb\tccc", "dddd\te\tf"]
320
+ widths = RuVim::RichView::TableRenderer.compute_col_widths(lines, delimiter: "\t")
321
+ assert_equal [4, 2, 3], widths
322
+ end
323
+
324
+ def test_compute_col_widths_single_column
325
+ lines = ["abc", "def"]
326
+ assert_nil RuVim::RichView::TableRenderer.compute_col_widths(lines, delimiter: "\t")
327
+ end
328
+
329
+ def test_compute_col_widths_empty
330
+ assert_nil RuVim::RichView::TableRenderer.compute_col_widths([], delimiter: "\t")
331
+ end
332
+
333
+ def test_compute_col_widths_cjk
334
+ lines = ["名前\t年齢", "Alice\t30"]
335
+ widths = RuVim::RichView::TableRenderer.compute_col_widths(lines, delimiter: "\t")
336
+ # 名前 = 4 display cols, Alice = 5 → max = 5
337
+ # 年齢 = 4 display cols, 30 = 2 → max = 4
338
+ assert_equal [5, 4], widths
339
+ end
340
+
341
+ def test_format_line_basic
342
+ col_widths = [4, 2, 3]
343
+ result = RuVim::RichView::TableRenderer.format_line("a\tbb\tccc", delimiter: "\t", col_widths: col_widths)
344
+ assert_equal "a | bb | ccc", result
345
+ end
346
+
347
+ def test_format_line_consistency_with_render_visible
348
+ lines = ["a\tbb\tccc", "dddd\te\tf"]
349
+ rendered = RuVim::RichView::TableRenderer.render_visible(lines, delimiter: "\t")
350
+ col_widths = RuVim::RichView::TableRenderer.compute_col_widths(lines, delimiter: "\t")
351
+ lines.each_with_index do |line, i|
352
+ formatted = RuVim::RichView::TableRenderer.format_line(line, delimiter: "\t", col_widths: col_widths)
353
+ assert_equal rendered[i], formatted, "format_line should match render_visible for line #{i}"
354
+ end
355
+ end
356
+
357
+ def test_raw_to_formatted_char_index_first_field
358
+ # Raw: "Hello\tWorld\tFoo"
359
+ col_widths = [10, 10, 5]
360
+ r = RuVim::RichView::TableRenderer
361
+ # 'H' at raw 0 → formatted 0
362
+ assert_equal 0, r.raw_to_formatted_char_index("Hello\tWorld\tFoo", 0, delimiter: "\t", col_widths: col_widths)
363
+ # 'o' at raw 4 → formatted 4
364
+ assert_equal 4, r.raw_to_formatted_char_index("Hello\tWorld\tFoo", 4, delimiter: "\t", col_widths: col_widths)
365
+ # End of first field at raw 5 → formatted 5 (just past 'o', still in padded area)
366
+ assert_equal 5, r.raw_to_formatted_char_index("Hello\tWorld\tFoo", 5, delimiter: "\t", col_widths: col_widths)
367
+ end
368
+
369
+ def test_raw_to_formatted_char_index_second_field
370
+ col_widths = [10, 10, 5]
371
+ r = RuVim::RichView::TableRenderer
372
+ # 'W' at raw 6 → formatted 10 (col_widths[0]) + 3 (separator) = 13
373
+ assert_equal 13, r.raw_to_formatted_char_index("Hello\tWorld\tFoo", 6, delimiter: "\t", col_widths: col_widths)
374
+ # 'd' at raw 10 → formatted 13 + 4 = 17
375
+ assert_equal 17, r.raw_to_formatted_char_index("Hello\tWorld\tFoo", 10, delimiter: "\t", col_widths: col_widths)
376
+ end
377
+
378
+ def test_raw_to_formatted_char_index_third_field
379
+ col_widths = [10, 10, 5]
380
+ r = RuVim::RichView::TableRenderer
381
+ # 'F' at raw 12 → formatted 10 + 3 + 10 + 3 = 26
382
+ assert_equal 26, r.raw_to_formatted_char_index("Hello\tWorld\tFoo", 12, delimiter: "\t", col_widths: col_widths)
383
+ # Last 'o' at raw 14 → formatted 26 + 2 = 28
384
+ assert_equal 28, r.raw_to_formatted_char_index("Hello\tWorld\tFoo", 14, delimiter: "\t", col_widths: col_widths)
385
+ end
386
+
387
+ def test_raw_to_formatted_alignment_across_rows
388
+ # When different rows map col_offset to different fields, the formatted
389
+ # positions should still be aligned (same column structure).
390
+ lines = ["Short\tField\tEnd", "LongerField\tF\tEnd"]
391
+ col_widths = RuVim::RichView::TableRenderer.compute_col_widths(lines, delimiter: "\t")
392
+ r = RuVim::RichView::TableRenderer
393
+
394
+ # Format both lines and verify separator positions match
395
+ f0 = r.format_line(lines[0], delimiter: "\t", col_widths: col_widths)
396
+ f1 = r.format_line(lines[1], delimiter: "\t", col_widths: col_widths)
397
+ assert_equal RuVim::DisplayWidth.display_width(f0), RuVim::DisplayWidth.display_width(f1)
398
+
399
+ # Map cursor at "End" field start for both lines — should give same formatted position
400
+ # Line 0: "Short\tField\tEnd" → raw 12 is 'E' in End
401
+ # Line 1: "LongerField\tF\tEnd" → raw 14 is 'E' in End
402
+ fi0 = r.raw_to_formatted_char_index(lines[0], 12, delimiter: "\t", col_widths: col_widths)
403
+ fi1 = r.raw_to_formatted_char_index(lines[1], 14, delimiter: "\t", col_widths: col_widths)
404
+ assert_equal fi0, fi1, "Same column start should map to same formatted position"
405
+ end
406
+
407
+ def test_raw_to_formatted_char_index_cjk
408
+ # CJK fields: "太郎" is 2 chars but 4 display cols
409
+ col_widths = [5, 4]
410
+ r = RuVim::RichView::TableRenderer
411
+ # "太郎\t30" → formatted: "太郎 " (2+1 pad) + " | " (3) + "30 " (2+2 pad)
412
+ # Character counts: field "太郎"(2) + pad(1) + separator(3) = 6
413
+ # So "3" at raw 3 → formatted 6
414
+ assert_equal 6, r.raw_to_formatted_char_index("太郎\t30", 3, delimiter: "\t", col_widths: col_widths)
415
+ # "0" at raw 4 → formatted 7
416
+ assert_equal 7, r.raw_to_formatted_char_index("太郎\t30", 4, delimiter: "\t", col_widths: col_widths)
417
+ end
418
+
419
+ def test_raw_to_formatted_display_col_basic
420
+ col_widths = [10, 10, 5]
421
+ r = RuVim::RichView::TableRenderer
422
+ # 'H' at raw 0 → display col 0
423
+ assert_equal 0, r.raw_to_formatted_display_col("Hello\tWorld\tFoo", 0, delimiter: "\t", col_widths: col_widths)
424
+ # 'o' at raw 4 → display col 4
425
+ assert_equal 4, r.raw_to_formatted_display_col("Hello\tWorld\tFoo", 4, delimiter: "\t", col_widths: col_widths)
426
+ # 'W' at raw 6 → display col 10 + 3 = 13
427
+ assert_equal 13, r.raw_to_formatted_display_col("Hello\tWorld\tFoo", 6, delimiter: "\t", col_widths: col_widths)
428
+ # 'F' at raw 12 → display col 10 + 3 + 10 + 3 = 26
429
+ assert_equal 26, r.raw_to_formatted_display_col("Hello\tWorld\tFoo", 12, delimiter: "\t", col_widths: col_widths)
430
+ end
431
+
432
+ def test_raw_to_formatted_display_col_cjk
433
+ # "太郎" = 2 chars, 4 display cols; "Alice" = 5 chars, 5 display cols → max = 5
434
+ # "30" = 2 chars, 2 display cols; "年齢" = 2 chars, 4 display cols → max = 4
435
+ col_widths = [5, 4]
436
+ r = RuVim::RichView::TableRenderer
437
+ # "太郎\t30"
438
+ # "太" at raw 0 → display col 0
439
+ assert_equal 0, r.raw_to_formatted_display_col("太郎\t30", 0, delimiter: "\t", col_widths: col_widths)
440
+ # "郎" at raw 1 → display col = dw("太") = 2
441
+ assert_equal 2, r.raw_to_formatted_display_col("太郎\t30", 1, delimiter: "\t", col_widths: col_widths)
442
+ # end of first field at raw 2 → display col = dw("太郎") = 4
443
+ assert_equal 4, r.raw_to_formatted_display_col("太郎\t30", 2, delimiter: "\t", col_widths: col_widths)
444
+ # "3" at raw 3 → display col = 5 (col_widths[0]) + 3 (separator) = 8
445
+ assert_equal 8, r.raw_to_formatted_display_col("太郎\t30", 3, delimiter: "\t", col_widths: col_widths)
446
+ # "0" at raw 4 → display col = 8 + dw("3") = 9
447
+ assert_equal 9, r.raw_to_formatted_display_col("太郎\t30", 4, delimiter: "\t", col_widths: col_widths)
448
+ end
449
+
450
+ def test_raw_to_formatted_display_col_alignment_across_cjk_rows
451
+ lines = ["太郎\t年齢", "Alice\t30"]
452
+ col_widths = RuVim::RichView::TableRenderer.compute_col_widths(lines, delimiter: "\t")
453
+ r = RuVim::RichView::TableRenderer
454
+ # Second field starts at same display col for both lines
455
+ # Line 0: "太郎\t年齢" → raw 3 is "年" → display col = col_widths[0]+3
456
+ # Line 1: "Alice\t30" → raw 6 is "3" → display col = col_widths[0]+3
457
+ dc0 = r.raw_to_formatted_display_col(lines[0], 3, delimiter: "\t", col_widths: col_widths)
458
+ dc1 = r.raw_to_formatted_display_col(lines[1], 6, delimiter: "\t", col_widths: col_widths)
459
+ assert_equal dc0, dc1, "Second field start should align across CJK and ASCII rows"
460
+ end
461
+
462
+ # --- JSON Rich View tests ---
463
+
464
+ def test_json_registered
465
+ assert RuVim::RichView.renderer_for("json")
466
+ end
467
+
468
+ def test_json_open_creates_virtual_buffer
469
+ editor = fresh_editor
470
+ buf = editor.current_buffer
471
+ buf.replace_all_lines!(['{"a":1,"b":[2,3]}'])
472
+ buf.options["filetype"] = "json"
473
+ count_before = editor.buffers.length
474
+
475
+ RuVim::RichView.open!(editor, format: "json")
476
+ assert_equal count_before + 1, editor.buffers.length
477
+ new_buf = editor.current_buffer
478
+ refute_equal buf.id, new_buf.id
479
+ assert_equal :json_formatted, new_buf.kind
480
+ assert new_buf.readonly?
481
+ end
482
+
483
+ def test_json_open_binds_close_keys
484
+ editor = fresh_editor
485
+ editor.keymap_manager = RuVim::KeymapManager.new
486
+ buf = editor.current_buffer
487
+ buf.replace_all_lines!(['{"a":1}'])
488
+ buf.options["filetype"] = "json"
489
+
490
+ RuVim::RichView.open!(editor, format: "json")
491
+ result = editor.keymap_manager.resolve_with_context(:normal, ["\e"], editor: editor)
492
+ assert_equal "rich.close_buffer", result.invocation.id
493
+ end
494
+
495
+ def test_json_open_pretty_prints
496
+ editor = fresh_editor
497
+ buf = editor.current_buffer
498
+ buf.replace_all_lines!(['{"a":1,"b":[2,3]}'])
499
+ buf.options["filetype"] = "json"
500
+
501
+ RuVim::RichView.open!(editor, format: "json")
502
+ new_buf = editor.current_buffer
503
+ lines = new_buf.lines
504
+ assert lines.length > 1, "Minified JSON should be expanded to multiple lines"
505
+ assert_equal "{", lines.first.strip
506
+ assert_equal "}", lines.last.strip
507
+ end
508
+
509
+ def test_json_open_multiline_buffer
510
+ editor = fresh_editor
511
+ buf = editor.current_buffer
512
+ buf.replace_all_lines!(['{', '"key": "value"', '}'])
513
+ buf.options["filetype"] = "json"
514
+
515
+ RuVim::RichView.open!(editor, format: "json")
516
+ new_buf = editor.current_buffer
517
+ lines = new_buf.lines
518
+ assert lines.length >= 3
519
+ end
520
+
521
+ def test_json_open_invalid_json_shows_error
522
+ editor = fresh_editor
523
+ buf = editor.current_buffer
524
+ buf.replace_all_lines!(['{"invalid json'])
525
+ buf.options["filetype"] = "json"
526
+
527
+ RuVim::RichView.open!(editor, format: "json")
528
+ # Should stay on original buffer
529
+ assert_equal buf.id, editor.current_buffer.id
530
+ assert_match(/JSON/, editor.message.to_s)
531
+ end
532
+
533
+ def test_json_open_does_not_enter_rich_mode
534
+ editor = fresh_editor
535
+ buf = editor.current_buffer
536
+ buf.replace_all_lines!(['{"a":1}'])
537
+ buf.options["filetype"] = "json"
538
+
539
+ RuVim::RichView.open!(editor, format: "json")
540
+ # Virtual buffer approach — no rich mode
541
+ assert_equal :normal, editor.mode
542
+ assert_nil editor.rich_state
543
+ end
544
+
545
+ def test_json_cursor_maps_to_formatted_line
546
+ editor = fresh_editor
547
+ buf = editor.current_buffer
548
+ # {"a":1,"b":{"c":2}}
549
+ buf.replace_all_lines!(['{"a":1,"b":{"c":2}}'])
550
+ buf.options["filetype"] = "json"
551
+
552
+ # Place cursor at "c" key — find its offset
553
+ line = buf.line_at(0)
554
+ idx = line.index('"c"')
555
+ editor.current_window.cursor_x = idx
556
+
557
+ RuVim::RichView.open!(editor, format: "json")
558
+ new_buf = editor.current_buffer
559
+ # Cursor should be on the line containing "c"
560
+ cursor_line = new_buf.line_at(editor.current_window.cursor_y)
561
+ assert_match(/"c"/, cursor_line, "Cursor should be on the line with \"c\" key")
562
+ end
563
+
564
+ def test_json_cursor_maps_multiline_source
565
+ editor = fresh_editor
566
+ buf = editor.current_buffer
567
+ buf.replace_all_lines!(['{', ' "x": [1, 2, 3]', '}'])
568
+ buf.options["filetype"] = "json"
569
+
570
+ # Place cursor on line 1 at the "x" key (col 2 = opening quote)
571
+ editor.current_window.cursor_y = 1
572
+ editor.current_window.cursor_x = 2
573
+
574
+ RuVim::RichView.open!(editor, format: "json")
575
+ new_buf = editor.current_buffer
576
+ cursor_line = new_buf.line_at(editor.current_window.cursor_y)
577
+ assert_match(/"x"/, cursor_line, "Cursor should be on the line with \"x\" key")
578
+ end
579
+
580
+ def test_json_cursor_at_start_stays_at_start
581
+ editor = fresh_editor
582
+ buf = editor.current_buffer
583
+ buf.replace_all_lines!(['{"a":1}'])
584
+ buf.options["filetype"] = "json"
585
+ editor.current_window.cursor_x = 0
586
+
587
+ RuVim::RichView.open!(editor, format: "json")
588
+ assert_equal 0, editor.current_window.cursor_y
589
+ end
590
+
591
+ def test_json_significant_offset
592
+ r = RuVim::RichView::JsonRenderer
593
+ # {"a" — 4 significant chars: { " a "
594
+ assert_equal 4, r.significant_char_count('{"a"', 4)
595
+ # { "a" — space outside string skipped, still 4 significant
596
+ assert_equal 4, r.significant_char_count('{ "a"', 5)
597
+ end
598
+
599
+ def test_json_line_for_significant_offset
600
+ formatted = "{\n \"a\": 1\n}"
601
+ r = RuVim::RichView::JsonRenderer
602
+ # count 0 → line 0 (before any char)
603
+ assert_equal 0, r.line_for_significant_count(formatted, 0)
604
+ # count 1 → line 0 ({ is the 1st significant char, on line 0)
605
+ assert_equal 0, r.line_for_significant_count(formatted, 1)
606
+ # count 2 → line 1 (" opening quote of "a" is on line 1)
607
+ assert_equal 1, r.line_for_significant_count(formatted, 2)
608
+ end
609
+
610
+ def test_json_filetype_detected
611
+ editor = fresh_editor
612
+ buf = editor.current_buffer
613
+ buf.options["filetype"] = "json"
614
+ assert_equal "json", RuVim::RichView.detect_format(buf)
615
+ end
616
+
617
+ # --- Filetype detection tests ---
618
+
619
+ def test_detect_filetype_tsv
620
+ editor = RuVim::Editor.new
621
+ assert_equal "tsv", editor.detect_filetype("data.tsv")
622
+ end
623
+
624
+ def test_detect_filetype_csv
625
+ editor = RuVim::Editor.new
626
+ assert_equal "csv", editor.detect_filetype("data.csv")
627
+ end
628
+
629
+ def test_detect_filetype_tsv_uppercase
630
+ editor = RuVim::Editor.new
631
+ assert_equal "tsv", editor.detect_filetype("DATA.TSV")
632
+ end
633
+
634
+ def test_detect_filetype_jsonl
635
+ editor = RuVim::Editor.new
636
+ assert_equal "jsonl", editor.detect_filetype("data.jsonl")
637
+ end
638
+
639
+ # --- JSONL Rich View tests ---
640
+
641
+ def test_jsonl_registered
642
+ assert RuVim::RichView.renderer_for("jsonl")
643
+ end
644
+
645
+ def test_jsonl_open_creates_virtual_buffer
646
+ editor = fresh_editor
647
+ buf = editor.current_buffer
648
+ buf.replace_all_lines!(['{"a":1}', '{"b":2}'])
649
+ buf.options["filetype"] = "jsonl"
650
+ count_before = editor.buffers.length
651
+
652
+ RuVim::RichView.open!(editor, format: "jsonl")
653
+ assert_equal count_before + 1, editor.buffers.length
654
+ new_buf = editor.current_buffer
655
+ refute_equal buf.id, new_buf.id
656
+ assert_equal :jsonl_formatted, new_buf.kind
657
+ assert new_buf.readonly?
658
+ end
659
+
660
+ def test_jsonl_open_binds_close_keys
661
+ editor = fresh_editor
662
+ editor.keymap_manager = RuVim::KeymapManager.new
663
+ buf = editor.current_buffer
664
+ buf.replace_all_lines!(['{"a":1}', '{"b":2}'])
665
+ buf.options["filetype"] = "jsonl"
666
+
667
+ RuVim::RichView.open!(editor, format: "jsonl")
668
+ result = editor.keymap_manager.resolve_with_context(:normal, ["\e"], editor: editor)
669
+ assert_equal "rich.close_buffer", result.invocation.id
670
+ end
671
+
672
+ def test_jsonl_open_pretty_prints_each_line
673
+ editor = fresh_editor
674
+ buf = editor.current_buffer
675
+ buf.replace_all_lines!(['{"a":1,"b":[2,3]}', '{"c":4}'])
676
+ buf.options["filetype"] = "jsonl"
677
+
678
+ RuVim::RichView.open!(editor, format: "jsonl")
679
+ new_buf = editor.current_buffer
680
+ lines = new_buf.lines
681
+ # Each JSON object should be expanded; separated by "---"
682
+ assert lines.length > 2, "JSONL should be expanded to multiple lines"
683
+ assert lines.any? { |l| l.include?("---") }, "Entries should be separated"
684
+ end
685
+
686
+ def test_jsonl_open_maps_cursor_to_correct_entry
687
+ editor = fresh_editor
688
+ buf = editor.current_buffer
689
+ buf.replace_all_lines!(['{"a":1}', '{"b":2}', '{"c":3}'])
690
+ buf.options["filetype"] = "jsonl"
691
+ editor.current_window.cursor_y = 1 # on second entry
692
+
693
+ RuVim::RichView.open!(editor, format: "jsonl")
694
+ new_buf = editor.current_buffer
695
+ cy = editor.current_window.cursor_y
696
+ # Cursor should be within the second entry's formatted block
697
+ nearby = (cy..[cy + 2, new_buf.lines.length - 1].min).map { |r| new_buf.line_at(r) }.join("\n")
698
+ assert_match(/"b"/, nearby, "Cursor should be near the entry with \"b\"")
699
+ end
700
+
701
+ def test_jsonl_open_skips_blank_lines
702
+ editor = fresh_editor
703
+ buf = editor.current_buffer
704
+ buf.replace_all_lines!(['{"a":1}', '', '{"b":2}'])
705
+ buf.options["filetype"] = "jsonl"
706
+
707
+ RuVim::RichView.open!(editor, format: "jsonl")
708
+ new_buf = editor.current_buffer
709
+ lines = new_buf.lines
710
+ # Should contain both entries
711
+ assert lines.any? { |l| l.include?('"a"') }
712
+ assert lines.any? { |l| l.include?('"b"') }
713
+ end
714
+
715
+ def test_jsonl_open_shows_parse_error_inline
716
+ editor = fresh_editor
717
+ buf = editor.current_buffer
718
+ buf.replace_all_lines!(['{"a":1}', 'bad json', '{"b":2}'])
719
+ buf.options["filetype"] = "jsonl"
720
+
721
+ RuVim::RichView.open!(editor, format: "jsonl")
722
+ new_buf = editor.current_buffer
723
+ lines = new_buf.lines
724
+ # Invalid line should show an error marker
725
+ assert lines.any? { |l| l.include?("PARSE ERROR") }, "Invalid JSON line should show error"
726
+ end
727
+
728
+ def test_jsonl_filetype_detected
729
+ editor = fresh_editor
730
+ buf = editor.current_buffer
731
+ buf.options["filetype"] = "jsonl"
732
+ assert_equal "jsonl", RuVim::RichView.detect_format(buf)
733
+ end
734
+ end