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
@@ -0,0 +1,478 @@
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
+ # --- Filetype detection tests ---
463
+
464
+ def test_detect_filetype_tsv
465
+ editor = RuVim::Editor.new
466
+ assert_equal "tsv", editor.detect_filetype("data.tsv")
467
+ end
468
+
469
+ def test_detect_filetype_csv
470
+ editor = RuVim::Editor.new
471
+ assert_equal "csv", editor.detect_filetype("data.csv")
472
+ end
473
+
474
+ def test_detect_filetype_tsv_uppercase
475
+ editor = RuVim::Editor.new
476
+ assert_equal "tsv", editor.detect_filetype("DATA.TSV")
477
+ end
478
+ end