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
@@ -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
@@ -33,6 +33,53 @@ class DispatcherTest < Minitest::Test
33
33
  assert_includes body, "Write current buffer"
34
34
  end
35
35
 
36
+ def test_dispatch_ex_bindings_lists_buffer_filetype_and_app_layers
37
+ keymaps = @app.instance_variable_get(:@keymaps)
38
+ @editor.current_buffer.options["filetype"] = "ruby"
39
+ keymaps.bind_buffer(@editor.current_buffer.id, "Q", "ui.clear_message")
40
+ keymaps.bind_filetype("ruby", "K", "cursor.up", mode: :normal)
41
+
42
+ @dispatcher.dispatch_ex(@editor, "bindings")
43
+
44
+ assert_equal "[Bindings]", @editor.message
45
+ assert_equal :help, @editor.current_buffer.kind
46
+ body = @editor.current_buffer.lines.join("\n")
47
+ assert_includes body, "Layer: buffer"
48
+ assert_includes body, "Layer: filetype"
49
+ assert_includes body, "Layer: app"
50
+ assert_operator body.index("Layer: buffer"), :<, body.index("Layer: filetype")
51
+ assert_operator body.index("Layer: filetype"), :<, body.index("Layer: app")
52
+ assert_includes body, "Q"
53
+ assert_includes body, "K"
54
+ assert_includes body, "gg"
55
+ assert_includes body, "f"
56
+ assert_includes body, "normal.find_char_forward_start"
57
+ assert_includes body, "Move to start of buffer"
58
+ assert_includes body, "Start char find forward"
59
+ assert_includes body, "Clear message"
60
+ end
61
+
62
+ def test_dispatch_ex_bindings_sort_command_sorts_within_group_by_command_id
63
+ keymaps = @app.instance_variable_get(:@keymaps)
64
+ keymaps.bind_buffer(@editor.current_buffer.id, "K", "ui.clear_message")
65
+ keymaps.bind_buffer(@editor.current_buffer.id, "Q", "cursor.up")
66
+
67
+ @dispatcher.dispatch_ex(@editor, "bindings sort=command")
68
+
69
+ body = @editor.current_buffer.lines.join("\n")
70
+ assert_includes body, "Sort: command"
71
+
72
+ buffer_section = body.split("Layer: buffer", 2).last
73
+ refute_nil buffer_section
74
+ buffer_section = buffer_section.split("Layer: app", 2).first.to_s
75
+
76
+ up_idx = buffer_section.index("cursor.up")
77
+ clear_idx = buffer_section.index("ui.clear_message")
78
+ refute_nil up_idx
79
+ refute_nil clear_idx
80
+ assert_operator up_idx, :<, clear_idx
81
+ end
82
+
36
83
  def test_dispatch_ex_command_and_ruby
37
84
  @dispatcher.dispatch_ex(@editor, "command Hi help")
38
85
  assert_equal "Defined :Hi", @editor.message
@@ -44,6 +91,20 @@ class DispatcherTest < Minitest::Test
44
91
  assert_equal "ruby: 3", @editor.message
45
92
  end
46
93
 
94
+ def test_dispatch_ex_commands_shows_description_and_bound_keys
95
+ keymaps = @app.instance_variable_get(:@keymaps)
96
+ keymaps.bind(:normal, "K", "editor.buffer_next")
97
+
98
+ @dispatcher.dispatch_ex(@editor, "commands")
99
+
100
+ assert_equal "[Commands]", @editor.message
101
+ assert_equal :help, @editor.current_buffer.kind
102
+ body = @editor.current_buffer.lines.join("\n")
103
+ assert_includes body, "bnext"
104
+ assert_includes body, "Next buffer"
105
+ assert_includes body, "keys: K"
106
+ end
107
+
47
108
  def test_dispatch_ex_ruby_captures_stdout_and_stderr_into_virtual_buffer
48
109
  @dispatcher.dispatch_ex(@editor, "ruby STDOUT.puts(%q[out]); STDERR.puts(%q[err]); 42")
49
110
 
@@ -224,6 +285,21 @@ class DispatcherTest < Minitest::Test
224
285
  refute @editor.message_error?
225
286
  end
226
287
 
288
+ def test_tabs_lists_all_tabpages
289
+ @editor.tabnew(path: nil)
290
+ @editor.tabnew(path: nil)
291
+ assert_equal 3, @editor.tabpage_count
292
+
293
+ @dispatcher.dispatch_ex(@editor, "tabs")
294
+
295
+ lines = @editor.hit_enter_lines
296
+ refute_nil lines, "tabs should produce multiline output"
297
+ joined = lines.join("\n")
298
+ assert_includes joined, "Tab page 1"
299
+ assert_includes joined, "Tab page 2"
300
+ assert_includes joined, "Tab page 3"
301
+ end
302
+
227
303
  def test_splitbelow_and_splitright_change_insertion_side
228
304
  @editor.set_option("splitbelow", false, scope: :global)
229
305
  first = @editor.current_window_id
@@ -237,4 +313,250 @@ class DispatcherTest < Minitest::Test
237
313
  idx = @editor.window_order.index(@editor.current_window_id)
238
314
  assert_equal 1, idx
239
315
  end
316
+
317
+ # --- Range parser tests ---
318
+
319
+ def test_parse_range_percent
320
+ @editor.materialize_intro_buffer!
321
+ @editor.current_buffer.replace_all_lines!(["a", "b", "c", "d", "e"])
322
+ result = @dispatcher.parse_range("%", @editor)
323
+ assert_equal 0, result[:range_start]
324
+ assert_equal 4, result[:range_end]
325
+ assert_equal "", result[:rest]
326
+ end
327
+
328
+ def test_parse_range_numeric_pair
329
+ @editor.materialize_intro_buffer!
330
+ @editor.current_buffer.replace_all_lines!(["a", "b", "c", "d", "e"])
331
+ result = @dispatcher.parse_range("1,5", @editor)
332
+ assert_equal 0, result[:range_start]
333
+ assert_equal 4, result[:range_end]
334
+ assert_equal "", result[:rest]
335
+ end
336
+
337
+ def test_parse_range_dot_and_dollar
338
+ @editor.materialize_intro_buffer!
339
+ @editor.current_buffer.replace_all_lines!(["a", "b", "c", "d", "e"])
340
+ @editor.current_window.cursor_y = 2
341
+
342
+ result = @dispatcher.parse_range(".,$", @editor)
343
+ assert_equal 2, result[:range_start]
344
+ assert_equal 4, result[:range_end]
345
+ assert_equal "", result[:rest]
346
+ end
347
+
348
+ def test_parse_range_with_offset
349
+ @editor.materialize_intro_buffer!
350
+ @editor.current_buffer.replace_all_lines!(["a", "b", "c", "d", "e"])
351
+ @editor.current_window.cursor_y = 1
352
+
353
+ result = @dispatcher.parse_range(".+2,$-1", @editor)
354
+ assert_equal 3, result[:range_start]
355
+ assert_equal 3, result[:range_end]
356
+ assert_equal "", result[:rest]
357
+ end
358
+
359
+ def test_parse_range_single_address
360
+ @editor.materialize_intro_buffer!
361
+ @editor.current_buffer.replace_all_lines!(["a", "b", "c"])
362
+ result = @dispatcher.parse_range("2", @editor)
363
+ assert_equal 1, result[:range_start]
364
+ assert_equal 1, result[:range_end]
365
+ assert_equal "", result[:rest]
366
+ end
367
+
368
+ def test_parse_range_returns_nil_for_no_range
369
+ @editor.materialize_intro_buffer!
370
+ result = @dispatcher.parse_range("help", @editor)
371
+ assert_nil result
372
+ end
373
+
374
+ def test_parse_range_with_rest
375
+ @editor.materialize_intro_buffer!
376
+ @editor.current_buffer.replace_all_lines!(["a", "b", "c", "d", "e"])
377
+ result = @dispatcher.parse_range("%s/a/b/g", @editor)
378
+ assert_equal 0, result[:range_start]
379
+ assert_equal 4, result[:range_end]
380
+ assert_equal "s/a/b/g", result[:rest]
381
+ end
382
+
383
+ def test_parse_range_mark_address
384
+ @editor.materialize_intro_buffer!
385
+ @editor.current_buffer.replace_all_lines!(["a", "b", "c", "d", "e"])
386
+ @editor.current_window.cursor_y = 1
387
+ @editor.set_mark("a")
388
+ @editor.current_window.cursor_y = 3
389
+ @editor.set_mark("b")
390
+
391
+ result = @dispatcher.parse_range("'a,'b", @editor)
392
+ assert_equal 1, result[:range_start]
393
+ assert_equal 3, result[:range_end]
394
+ assert_equal "", result[:rest]
395
+ end
396
+
397
+ # --- Substitute parser tests ---
398
+
399
+ def test_parse_substitute_basic
400
+ result = @dispatcher.parse_substitute("s/foo/bar/")
401
+ assert_equal "foo", result[:pattern]
402
+ assert_equal "bar", result[:replacement]
403
+ assert_equal "", result[:flags_str]
404
+ end
405
+
406
+ def test_parse_substitute_with_flags
407
+ result = @dispatcher.parse_substitute("s/foo/bar/gi")
408
+ assert_equal "foo", result[:pattern]
409
+ assert_equal "bar", result[:replacement]
410
+ assert_equal "gi", result[:flags_str]
411
+ end
412
+
413
+ def test_parse_substitute_returns_nil_for_non_substitute
414
+ assert_nil @dispatcher.parse_substitute("help")
415
+ assert_nil @dispatcher.parse_substitute("set number")
416
+ end
417
+
418
+ # --- Substitute with range integration ---
419
+
420
+ def test_substitute_with_range
421
+ @editor.materialize_intro_buffer!
422
+ @editor.current_buffer.replace_all_lines!(["foo", "foo", "foo", "foo", "foo"])
423
+
424
+ @dispatcher.dispatch_ex(@editor, "1,3s/foo/bar/")
425
+
426
+ assert_equal "bar", @editor.current_buffer.line_at(0)
427
+ assert_equal "bar", @editor.current_buffer.line_at(1)
428
+ assert_equal "bar", @editor.current_buffer.line_at(2)
429
+ assert_equal "foo", @editor.current_buffer.line_at(3)
430
+ assert_equal "foo", @editor.current_buffer.line_at(4)
431
+ end
432
+
433
+ def test_substitute_percent_range
434
+ @editor.materialize_intro_buffer!
435
+ @editor.current_buffer.replace_all_lines!(["foo", "bar", "foo"])
436
+
437
+ @dispatcher.dispatch_ex(@editor, "%s/foo/baz/")
438
+
439
+ assert_equal "baz", @editor.current_buffer.line_at(0)
440
+ assert_equal "bar", @editor.current_buffer.line_at(1)
441
+ assert_equal "baz", @editor.current_buffer.line_at(2)
442
+ end
443
+
444
+ def test_substitute_ignore_case_flag
445
+ @editor.materialize_intro_buffer!
446
+ @editor.current_buffer.replace_all_lines!(["FOO", "foo", "Foo"])
447
+
448
+ @dispatcher.dispatch_ex(@editor, "%s/foo/bar/gi")
449
+
450
+ assert_equal "bar", @editor.current_buffer.line_at(0)
451
+ assert_equal "bar", @editor.current_buffer.line_at(1)
452
+ assert_equal "bar", @editor.current_buffer.line_at(2)
453
+ end
454
+
455
+ def test_substitute_count_only_flag
456
+ @editor.materialize_intro_buffer!
457
+ @editor.current_buffer.replace_all_lines!(["foo", "bar", "foo baz foo"])
458
+
459
+ @dispatcher.dispatch_ex(@editor, "%s/foo/x/gn")
460
+
461
+ # n flag: count only, no changes
462
+ assert_equal "foo", @editor.current_buffer.line_at(0)
463
+ assert_equal "foo baz foo", @editor.current_buffer.line_at(2)
464
+ assert_match(/3 match/, @editor.message)
465
+ end
466
+
467
+ def test_substitute_no_range_defaults_to_whole_buffer
468
+ @editor.materialize_intro_buffer!
469
+ @editor.current_buffer.replace_all_lines!(["aaa", "bbb", "aaa"])
470
+
471
+ @dispatcher.dispatch_ex(@editor, "s/aaa/zzz/")
472
+
473
+ assert_equal "zzz", @editor.current_buffer.line_at(0)
474
+ assert_equal "bbb", @editor.current_buffer.line_at(1)
475
+ assert_equal "zzz", @editor.current_buffer.line_at(2)
476
+ end
477
+
478
+ # --- :d (delete lines) tests ---
479
+
480
+ def test_delete_lines_with_range
481
+ @editor.materialize_intro_buffer!
482
+ @editor.current_buffer.replace_all_lines!(["a", "b", "c", "d", "e"])
483
+
484
+ @dispatcher.dispatch_ex(@editor, "2,4d")
485
+
486
+ assert_equal 2, @editor.current_buffer.line_count
487
+ assert_equal "a", @editor.current_buffer.line_at(0)
488
+ assert_equal "e", @editor.current_buffer.line_at(1)
489
+ assert_match(/3 line/, @editor.message)
490
+ end
491
+
492
+ def test_delete_lines_without_range_deletes_current
493
+ @editor.materialize_intro_buffer!
494
+ @editor.current_buffer.replace_all_lines!(["a", "b", "c"])
495
+ @editor.current_window.cursor_y = 1
496
+
497
+ @dispatcher.dispatch_ex(@editor, "d")
498
+
499
+ assert_equal 2, @editor.current_buffer.line_count
500
+ assert_equal "a", @editor.current_buffer.line_at(0)
501
+ assert_equal "c", @editor.current_buffer.line_at(1)
502
+ end
503
+
504
+ # --- :y (yank lines) tests ---
505
+
506
+ def test_yank_lines_with_range
507
+ @editor.materialize_intro_buffer!
508
+ @editor.current_buffer.replace_all_lines!(["a", "b", "c", "d", "e"])
509
+
510
+ @dispatcher.dispatch_ex(@editor, "1,3y")
511
+
512
+ assert_match(/3 line/, @editor.message)
513
+ # Buffer unchanged
514
+ assert_equal 5, @editor.current_buffer.line_count
515
+ end
516
+
517
+ def test_yank_lines_without_range_yanks_current
518
+ @editor.materialize_intro_buffer!
519
+ @editor.current_buffer.replace_all_lines!(["a", "b", "c"])
520
+ @editor.current_window.cursor_y = 1
521
+
522
+ @dispatcher.dispatch_ex(@editor, "y")
523
+
524
+ assert_match(/1 line/, @editor.message)
525
+ assert_equal 3, @editor.current_buffer.line_count
526
+ end
527
+
528
+ # --- :grep tests ---
529
+
530
+ def test_grep_populates_quickfix
531
+ Dir.mktmpdir("ruvim-grep") do |dir|
532
+ File.write(File.join(dir, "a.txt"), "hello world\ngoodbye\n")
533
+ File.write(File.join(dir, "b.txt"), "hello again\n")
534
+
535
+ @editor.materialize_intro_buffer!
536
+ @dispatcher.dispatch_ex(@editor, "grep hello #{File.join(dir, '*.txt')}")
537
+
538
+ assert_operator @editor.quickfix_items.length, :>=, 2
539
+ refute @editor.message_error?
540
+ end
541
+ end
542
+
543
+ def test_grep_no_matches_shows_error
544
+ @editor.materialize_intro_buffer!
545
+ @dispatcher.dispatch_ex(@editor, "grep ZZZZUNMATCHABLE /dev/null")
546
+
547
+ assert @editor.message_error?
548
+ end
549
+
550
+ def test_lgrep_populates_location_list
551
+ Dir.mktmpdir("ruvim-lgrep") do |dir|
552
+ File.write(File.join(dir, "c.txt"), "alpha\nbeta\nalpha\n")
553
+
554
+ @editor.materialize_intro_buffer!
555
+ wid = @editor.current_window_id
556
+ @dispatcher.dispatch_ex(@editor, "lgrep alpha #{File.join(dir, 'c.txt')}")
557
+
558
+ assert_operator @editor.location_items(wid).length, :>=, 2
559
+ refute @editor.message_error?
560
+ end
561
+ end
240
562
  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
@@ -1,4 +1,5 @@
1
1
  require_relative "test_helper"
2
+ require "tmpdir"
2
3
 
3
4
  class EditorRegisterTest < Minitest::Test
4
5
  def test_named_register_and_append_register
@@ -61,4 +62,26 @@ class EditorRegisterTest < Minitest::Test
61
62
  buffer = editor.add_empty_buffer(path: "/tmp/example.rb")
62
63
  assert_equal "ruby", buffer.options["filetype"]
63
64
  end
65
+
66
+ def test_detect_filetype_from_shebang_when_extension_is_missing
67
+ editor = RuVim::Editor.new
68
+ Dir.mktmpdir("ruvim-shebang-ft") do |dir|
69
+ path = File.join(dir, "script")
70
+ File.binwrite(path, "#!/usr/bin/env ruby\nputs :ok\n")
71
+
72
+ buffer = editor.add_buffer_from_file(path)
73
+ assert_equal "ruby", buffer.options["filetype"]
74
+ end
75
+ end
76
+
77
+ def test_detect_filetype_from_env_shebang_with_dash_s
78
+ editor = RuVim::Editor.new
79
+ Dir.mktmpdir("ruvim-shebang-envs") do |dir|
80
+ path = File.join(dir, "tool")
81
+ File.binwrite(path, "#!/usr/bin/env -S python3 -u\nprint('ok')\n")
82
+
83
+ buffer = editor.add_buffer_from_file(path)
84
+ assert_equal "python", buffer.options["filetype"]
85
+ end
86
+ end
64
87
  end