ruvim 0.3.0 → 0.6.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 (129) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +68 -7
  3. data/README.md +30 -7
  4. data/Rakefile +7 -0
  5. data/benchmark/cext_compare.rb +165 -0
  6. data/benchmark/chunked_load.rb +256 -0
  7. data/benchmark/file_load.rb +140 -0
  8. data/benchmark/hotspots.rb +178 -0
  9. data/docs/binding.md +18 -1
  10. data/docs/command.md +156 -10
  11. data/docs/config.md +10 -2
  12. data/docs/done.md +23 -0
  13. data/docs/spec.md +162 -25
  14. data/docs/todo.md +9 -0
  15. data/docs/tutorial.md +33 -1
  16. data/docs/vim_diff.md +31 -8
  17. data/ext/ruvim/extconf.rb +5 -0
  18. data/ext/ruvim/ruvim_ext.c +519 -0
  19. data/lib/ruvim/app.rb +246 -2525
  20. data/lib/ruvim/browser.rb +104 -0
  21. data/lib/ruvim/buffer.rb +43 -20
  22. data/lib/ruvim/cli.rb +6 -0
  23. data/lib/ruvim/command_invocation.rb +2 -2
  24. data/lib/ruvim/completion_manager.rb +708 -0
  25. data/lib/ruvim/dispatcher.rb +14 -8
  26. data/lib/ruvim/display_width.rb +91 -45
  27. data/lib/ruvim/editor.rb +74 -80
  28. data/lib/ruvim/ex_command_registry.rb +3 -1
  29. data/lib/ruvim/file_watcher.rb +243 -0
  30. data/lib/ruvim/gh/link.rb +207 -0
  31. data/lib/ruvim/git/blame.rb +255 -0
  32. data/lib/ruvim/git/branch.rb +112 -0
  33. data/lib/ruvim/git/commit.rb +102 -0
  34. data/lib/ruvim/git/diff.rb +129 -0
  35. data/lib/ruvim/git/grep.rb +107 -0
  36. data/lib/ruvim/git/handler.rb +125 -0
  37. data/lib/ruvim/git/log.rb +41 -0
  38. data/lib/ruvim/git/status.rb +103 -0
  39. data/lib/ruvim/global_commands.rb +351 -77
  40. data/lib/ruvim/highlighter.rb +4 -11
  41. data/lib/ruvim/input.rb +1 -0
  42. data/lib/ruvim/key_handler.rb +1510 -0
  43. data/lib/ruvim/keymap_manager.rb +7 -7
  44. data/lib/ruvim/lang/base.rb +5 -0
  45. data/lib/ruvim/lang/c.rb +116 -0
  46. data/lib/ruvim/lang/cpp.rb +107 -0
  47. data/lib/ruvim/lang/csv.rb +4 -1
  48. data/lib/ruvim/lang/diff.rb +43 -0
  49. data/lib/ruvim/lang/dockerfile.rb +36 -0
  50. data/lib/ruvim/lang/elixir.rb +85 -0
  51. data/lib/ruvim/lang/erb.rb +30 -0
  52. data/lib/ruvim/lang/go.rb +83 -0
  53. data/lib/ruvim/lang/html.rb +34 -0
  54. data/lib/ruvim/lang/javascript.rb +83 -0
  55. data/lib/ruvim/lang/json.rb +40 -0
  56. data/lib/ruvim/lang/lua.rb +76 -0
  57. data/lib/ruvim/lang/makefile.rb +36 -0
  58. data/lib/ruvim/lang/markdown.rb +3 -4
  59. data/lib/ruvim/lang/ocaml.rb +77 -0
  60. data/lib/ruvim/lang/perl.rb +91 -0
  61. data/lib/ruvim/lang/python.rb +85 -0
  62. data/lib/ruvim/lang/registry.rb +102 -0
  63. data/lib/ruvim/lang/ruby.rb +7 -0
  64. data/lib/ruvim/lang/rust.rb +95 -0
  65. data/lib/ruvim/lang/scheme.rb +5 -0
  66. data/lib/ruvim/lang/sh.rb +76 -0
  67. data/lib/ruvim/lang/sql.rb +52 -0
  68. data/lib/ruvim/lang/toml.rb +36 -0
  69. data/lib/ruvim/lang/tsv.rb +4 -1
  70. data/lib/ruvim/lang/typescript.rb +53 -0
  71. data/lib/ruvim/lang/yaml.rb +62 -0
  72. data/lib/ruvim/rich_view/json_renderer.rb +131 -0
  73. data/lib/ruvim/rich_view/jsonl_renderer.rb +57 -0
  74. data/lib/ruvim/rich_view/table_renderer.rb +3 -3
  75. data/lib/ruvim/rich_view.rb +30 -7
  76. data/lib/ruvim/screen.rb +135 -84
  77. data/lib/ruvim/stream/file_load.rb +85 -0
  78. data/lib/ruvim/stream/follow.rb +40 -0
  79. data/lib/ruvim/stream/git.rb +43 -0
  80. data/lib/ruvim/stream/run.rb +74 -0
  81. data/lib/ruvim/stream/stdin.rb +55 -0
  82. data/lib/ruvim/stream.rb +35 -0
  83. data/lib/ruvim/stream_mixer.rb +394 -0
  84. data/lib/ruvim/terminal.rb +18 -4
  85. data/lib/ruvim/text_metrics.rb +84 -65
  86. data/lib/ruvim/version.rb +1 -1
  87. data/lib/ruvim/window.rb +5 -5
  88. data/lib/ruvim.rb +31 -4
  89. data/test/app_command_test.rb +382 -0
  90. data/test/app_completion_test.rb +65 -16
  91. data/test/app_dot_repeat_test.rb +27 -3
  92. data/test/app_ex_command_test.rb +154 -0
  93. data/test/app_motion_test.rb +13 -12
  94. data/test/app_register_test.rb +2 -1
  95. data/test/app_scenario_test.rb +182 -8
  96. data/test/app_startup_test.rb +70 -27
  97. data/test/app_text_object_test.rb +2 -1
  98. data/test/app_unicode_behavior_test.rb +3 -2
  99. data/test/browser_test.rb +88 -0
  100. data/test/buffer_test.rb +24 -0
  101. data/test/cli_test.rb +77 -0
  102. data/test/clipboard_test.rb +67 -0
  103. data/test/command_invocation_test.rb +33 -0
  104. data/test/command_line_test.rb +118 -0
  105. data/test/config_dsl_test.rb +134 -0
  106. data/test/dispatcher_test.rb +74 -4
  107. data/test/display_width_test.rb +41 -0
  108. data/test/ex_command_registry_test.rb +106 -0
  109. data/test/file_watcher_test.rb +197 -0
  110. data/test/follow_test.rb +198 -0
  111. data/test/gh_link_test.rb +141 -0
  112. data/test/git_blame_test.rb +792 -0
  113. data/test/git_grep_test.rb +64 -0
  114. data/test/highlighter_test.rb +169 -0
  115. data/test/indent_test.rb +223 -0
  116. data/test/input_screen_integration_test.rb +1 -1
  117. data/test/keyword_chars_test.rb +85 -0
  118. data/test/lang_test.rb +634 -0
  119. data/test/markdown_renderer_test.rb +5 -5
  120. data/test/on_save_hook_test.rb +12 -8
  121. data/test/render_snapshot_test.rb +78 -0
  122. data/test/rich_view_test.rb +279 -23
  123. data/test/run_command_test.rb +307 -0
  124. data/test/screen_test.rb +68 -5
  125. data/test/search_option_test.rb +19 -0
  126. data/test/stream_test.rb +165 -0
  127. data/test/test_helper.rb +9 -0
  128. data/test/window_test.rb +59 -0
  129. metadata +68 -2
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+ require "ruvim/file_watcher"
5
+ require "tempfile"
6
+
7
+ class FileWatcherTest < Minitest::Test
8
+ def setup
9
+ @tmpfile = Tempfile.new("file_watcher_test")
10
+ @tmpfile.write("initial\n")
11
+ @tmpfile.flush
12
+ @path = @tmpfile.path
13
+ end
14
+
15
+ def teardown
16
+ @watcher&.stop
17
+ @tmpfile&.close!
18
+ end
19
+
20
+ def test_polling_watcher_detects_append
21
+ received = Queue.new
22
+ @watcher = RuVim::FileWatcher::PollingWatcher.new(@path) do |type, data|
23
+ received << [type, data]
24
+ end
25
+ @watcher.start
26
+
27
+ File.open(@path, "a") { |f| f.write("appended\n") }
28
+
29
+ event = nil
30
+ assert_eventually(timeout: 2) { event = received.pop(true) rescue nil; !event.nil? }
31
+ assert_equal :data, event[0]
32
+ assert_includes event[1], "appended"
33
+ ensure
34
+ @watcher&.stop
35
+ end
36
+
37
+ def test_polling_watcher_stop
38
+ @watcher = RuVim::FileWatcher::PollingWatcher.new(@path) { |_, _| }
39
+ @watcher.start
40
+ assert @watcher.alive?
41
+ @watcher.stop
42
+ refute @watcher.alive?
43
+ end
44
+
45
+ def test_polling_watcher_detects_truncation
46
+ received = Queue.new
47
+ @watcher = RuVim::FileWatcher::PollingWatcher.new(@path) do |type, data|
48
+ received << [type, data]
49
+ end
50
+ @watcher.start
51
+
52
+ # Append first so offset advances
53
+ File.open(@path, "a") { |f| f.write("extra\n") }
54
+ assert_eventually(timeout: 2) { received.pop(true) rescue nil }
55
+
56
+ # Truncate the file
57
+ File.write(@path, "")
58
+
59
+ event = nil
60
+ assert_eventually(timeout: 3) { event = received.pop(true) rescue nil; event&.first == :truncated }
61
+ assert_equal :truncated, event[0]
62
+ assert_nil event[1]
63
+ ensure
64
+ @watcher&.stop
65
+ end
66
+
67
+ def test_polling_watcher_detects_deletion
68
+ received = Queue.new
69
+ @watcher = RuVim::FileWatcher::PollingWatcher.new(@path) do |type, data|
70
+ received << [type, data]
71
+ end
72
+ @watcher.start
73
+
74
+ File.delete(@path)
75
+
76
+ event = nil
77
+ assert_eventually(timeout: 3) { event = received.pop(true) rescue nil; event&.first == :deleted }
78
+ assert_equal :deleted, event[0]
79
+ assert_nil event[1]
80
+ ensure
81
+ @watcher&.stop
82
+ end
83
+
84
+ def test_polling_watcher_waits_for_missing_file
85
+ missing_path = File.join(Dir.pwd, "follow_missing_test_#{$$}.log")
86
+ File.delete(missing_path) if File.exist?(missing_path)
87
+
88
+ received = Queue.new
89
+ watcher = RuVim::FileWatcher::PollingWatcher.new(missing_path) do |type, data|
90
+ received << [type, data]
91
+ end
92
+ watcher.start
93
+
94
+ sleep 0.2
95
+ assert watcher.alive?
96
+
97
+ File.write(missing_path, "hello\n")
98
+
99
+ event = nil
100
+ assert_eventually(timeout: 3) { event = received.pop(true) rescue nil; !event.nil? }
101
+ assert_equal :data, event[0]
102
+ assert_includes event[1], "hello"
103
+ ensure
104
+ watcher&.stop
105
+ File.delete(missing_path) if File.exist?(missing_path)
106
+ end
107
+
108
+ def test_inotify_watcher_detects_append
109
+ skip "inotify not available" unless RuVim::FileWatcher::InotifyWatcher.available?
110
+
111
+ received = Queue.new
112
+ @watcher = RuVim::FileWatcher::InotifyWatcher.new(@path) do |type, data|
113
+ received << [type, data]
114
+ end
115
+ @watcher.start
116
+
117
+ File.open(@path, "a") { |f| f.write("inotify appended\n") }
118
+
119
+ event = nil
120
+ assert_eventually(timeout: 2) { event = received.pop(true) rescue nil; !event.nil? }
121
+ assert_equal :data, event[0]
122
+ assert_includes event[1], "inotify appended"
123
+ ensure
124
+ @watcher&.stop
125
+ end
126
+
127
+ def test_inotify_watcher_stop
128
+ skip "inotify not available" unless RuVim::FileWatcher::InotifyWatcher.available?
129
+
130
+ @watcher = RuVim::FileWatcher::InotifyWatcher.new(@path) { |_, _| }
131
+ @watcher.start
132
+ assert @watcher.alive?
133
+ @watcher.stop
134
+ refute @watcher.alive?
135
+ end
136
+
137
+ def test_inotify_watcher_detects_truncation
138
+ skip "inotify not available" unless RuVim::FileWatcher::InotifyWatcher.available?
139
+
140
+ received = Queue.new
141
+ @watcher = RuVim::FileWatcher::InotifyWatcher.new(@path) do |type, data|
142
+ received << [type, data]
143
+ end
144
+ @watcher.start
145
+
146
+ File.open(@path, "a") { |f| f.write("extra\n") }
147
+ assert_eventually(timeout: 2) { received.pop(true) rescue nil }
148
+
149
+ File.write(@path, "")
150
+
151
+ event = nil
152
+ assert_eventually(timeout: 3) { event = received.pop(true) rescue nil; event&.first == :truncated }
153
+ assert_equal :truncated, event[0]
154
+ ensure
155
+ @watcher&.stop
156
+ end
157
+
158
+ def test_create_prefers_inotify
159
+ watcher = RuVim::FileWatcher.create(@path) { |_, _| }
160
+ if RuVim::FileWatcher::InotifyWatcher.available?
161
+ assert_kind_of RuVim::FileWatcher::InotifyWatcher, watcher
162
+ else
163
+ assert_kind_of RuVim::FileWatcher::PollingWatcher, watcher
164
+ end
165
+ ensure
166
+ watcher&.stop
167
+ end
168
+
169
+ def test_create_falls_back_to_polling_for_missing_file
170
+ missing_path = File.join(Dir.pwd, "follow_create_test_#{$$}.log")
171
+ File.delete(missing_path) if File.exist?(missing_path)
172
+
173
+ watcher = RuVim::FileWatcher.create(missing_path) { |_, _| }
174
+ assert_kind_of RuVim::FileWatcher::PollingWatcher, watcher
175
+ ensure
176
+ watcher&.stop
177
+ File.delete(missing_path) if File.exist?(missing_path)
178
+ end
179
+
180
+ def test_polling_backoff_resets_on_change
181
+ @watcher = RuVim::FileWatcher::PollingWatcher.new(@path) { |_, _| }
182
+ assert_equal RuVim::FileWatcher::PollingWatcher::MIN_INTERVAL, @watcher.current_interval
183
+ end
184
+
185
+ private
186
+
187
+ def assert_eventually(timeout: 2, interval: 0.05)
188
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
189
+ loop do
190
+ return if yield
191
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
192
+ flunk "Timed out waiting for condition"
193
+ end
194
+ sleep interval
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+ require "tempfile"
5
+
6
+ class FollowTest < Minitest::Test
7
+ def create_follow_app
8
+ @tmpfile = Tempfile.new(["follow_test", ".txt"])
9
+ @tmpfile.write("line1\nline2\n")
10
+ @tmpfile.flush
11
+ @path = @tmpfile.path
12
+
13
+ @app = RuVim::App.new(path: @path, clean: true)
14
+ @editor = @app.instance_variable_get(:@editor)
15
+ @dispatcher = @app.instance_variable_get(:@dispatcher)
16
+ @key_handler = @app.instance_variable_get(:@key_handler)
17
+ @stream_mixer = @app.instance_variable_get(:@stream_mixer)
18
+ end
19
+
20
+ def cleanup_follow_app
21
+ return unless @app
22
+
23
+ editor = @app&.instance_variable_get(:@editor)
24
+ return unless editor
25
+ editor.buffers.each_value do |buf|
26
+ buf.stream&.stop! rescue nil
27
+ end
28
+ @tmpfile&.close!
29
+ end
30
+
31
+ def test_follow_starts_on_file_buffer
32
+ create_follow_app
33
+ @dispatcher.dispatch_ex(@editor, "follow")
34
+ buf = @editor.current_buffer
35
+
36
+ assert !@editor.message_error?, "Unexpected error: #{@editor.message}"
37
+ assert_equal :live, buf.stream.state
38
+ assert buf.stream.watcher
39
+ assert_includes @editor.message.to_s, "[follow]"
40
+ ensure
41
+ cleanup_follow_app
42
+ end
43
+
44
+ def test_follow_stops_on_ctrl_c
45
+ create_follow_app
46
+ @dispatcher.dispatch_ex(@editor, "follow")
47
+ buf = @editor.current_buffer
48
+ assert_equal :live, buf.stream.state
49
+
50
+ @key_handler.handle(:ctrl_c)
51
+ assert_nil buf.stream
52
+ assert_includes @editor.message.to_s, "stopped"
53
+ ensure
54
+ cleanup_follow_app
55
+ end
56
+
57
+ def test_follow_toggle_stops
58
+ create_follow_app
59
+ @dispatcher.dispatch_ex(@editor, "follow")
60
+ buf = @editor.current_buffer
61
+ assert_equal :live, buf.stream.state
62
+
63
+ @dispatcher.dispatch_ex(@editor, "follow")
64
+ assert_nil buf.stream
65
+ assert_includes @editor.message.to_s, "stopped"
66
+ ensure
67
+ cleanup_follow_app
68
+ end
69
+
70
+ def test_follow_stop_removes_sentinel_empty_line
71
+ create_follow_app
72
+ buf = @editor.current_buffer
73
+ lines_before = buf.line_count
74
+
75
+ @dispatcher.dispatch_ex(@editor, "follow")
76
+ @dispatcher.dispatch_ex(@editor, "follow")
77
+
78
+ assert_equal lines_before, buf.line_count, "Sentinel empty line should be removed on stop"
79
+ refute_equal "", buf.lines.last, "Last line should not be empty sentinel"
80
+ ensure
81
+ cleanup_follow_app
82
+ end
83
+
84
+ def test_follow_makes_buffer_not_modifiable
85
+ create_follow_app
86
+ buf = @editor.current_buffer
87
+ assert buf.modifiable?
88
+
89
+ @dispatcher.dispatch_ex(@editor, "follow")
90
+ refute buf.modifiable?, "Buffer should not be modifiable during follow"
91
+
92
+ @dispatcher.dispatch_ex(@editor, "follow")
93
+ assert buf.modifiable?, "Buffer should be modifiable after follow stops"
94
+ ensure
95
+ cleanup_follow_app
96
+ end
97
+
98
+ def test_follow_error_on_modified_buffer
99
+ create_follow_app
100
+ buf = @editor.current_buffer
101
+ buf.modified = true
102
+
103
+ @dispatcher.dispatch_ex(@editor, "follow")
104
+ assert_includes @editor.message.to_s, "unsaved changes"
105
+ assert_nil buf.stream
106
+ ensure
107
+ cleanup_follow_app
108
+ end
109
+
110
+ def test_follow_error_on_no_path_buffer
111
+ @app = RuVim::App.new(clean: true)
112
+ @editor = @app.instance_variable_get(:@editor)
113
+ @dispatcher = @app.instance_variable_get(:@dispatcher)
114
+ @editor.materialize_intro_buffer!
115
+ buf = @editor.current_buffer
116
+ buf.instance_variable_set(:@path, nil)
117
+
118
+ @dispatcher.dispatch_ex(@editor, "follow")
119
+ assert_includes @editor.message.to_s, "No file"
120
+ end
121
+
122
+ def test_follow_appends_data_from_file
123
+ create_follow_app
124
+ win = @editor.current_window
125
+ buf = @editor.current_buffer
126
+ win.cursor_y = buf.line_count - 1
127
+
128
+ @dispatcher.dispatch_ex(@editor, "follow")
129
+
130
+ File.open(@path, "a") { |f| f.write("line3\nline4\n") }
131
+
132
+ assert_eventually(timeout: 3) do
133
+ @stream_mixer.drain_events!
134
+ buf.line_count > 3
135
+ end
136
+
137
+ # line2 should NOT be joined with line3
138
+ assert_includes buf.lines, "line2"
139
+ assert_includes buf.lines, "line3"
140
+ assert_includes buf.lines, "line4"
141
+ assert_equal buf.line_count - 1, win.cursor_y
142
+ ensure
143
+ cleanup_follow_app
144
+ end
145
+
146
+ def test_follow_no_scroll_when_cursor_not_at_end
147
+ create_follow_app
148
+ buf = @editor.current_buffer
149
+ win = @editor.current_window
150
+ win.cursor_y = 0
151
+
152
+ @dispatcher.dispatch_ex(@editor, "follow")
153
+
154
+ File.open(@path, "a") { |f| f.write("line3\n") }
155
+
156
+ assert_eventually(timeout: 3) do
157
+ @stream_mixer.drain_events!
158
+ buf.line_count > 2
159
+ end
160
+
161
+ assert_equal 0, win.cursor_y
162
+ ensure
163
+ cleanup_follow_app
164
+ end
165
+
166
+ def test_startup_follow_applies_to_all_buffers
167
+ tmp1 = Tempfile.new(["follow_multi1", ".txt"])
168
+ tmp1.write("aaa\n"); tmp1.flush
169
+ tmp2 = Tempfile.new(["follow_multi2", ".txt"])
170
+ tmp2.write("bbb\n"); tmp2.flush
171
+
172
+ app = RuVim::App.new(paths: [tmp1.path, tmp2.path], follow: true, clean: true)
173
+ editor = app.instance_variable_get(:@editor)
174
+ bufs = editor.buffers.values.select(&:file_buffer?)
175
+ assert_equal 2, bufs.size
176
+ bufs.each do |buf|
177
+ assert_equal :live, buf.stream.state, "#{buf.display_name} should be in follow mode"
178
+ assert buf.stream.watcher, "#{buf.display_name} should have a watcher"
179
+ end
180
+ ensure
181
+ bufs&.each { |b| b.stream&.stop! rescue nil }
182
+ tmp1&.close!
183
+ tmp2&.close!
184
+ end
185
+
186
+ private
187
+
188
+ def assert_eventually(timeout: 2, interval: 0.05)
189
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
190
+ loop do
191
+ return if yield
192
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
193
+ flunk "Timed out waiting for condition"
194
+ end
195
+ sleep interval
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class GhLinkTest < Minitest::Test
6
+ def setup
7
+ @app = RuVim::App.new(clean: true)
8
+ @editor = @app.instance_variable_get(:@editor)
9
+ @dispatcher = @app.instance_variable_get(:@dispatcher)
10
+ @editor.materialize_intro_buffer!
11
+ end
12
+
13
+ # --- URL parsing ---
14
+
15
+ def test_parse_ssh_remote
16
+ url = RuVim::Gh::Link.github_url_from_remote("git@github.com:user/repo.git")
17
+ assert_equal "https://github.com/user/repo", url
18
+ end
19
+
20
+ def test_parse_ssh_remote_without_dot_git
21
+ url = RuVim::Gh::Link.github_url_from_remote("git@github.com:user/repo")
22
+ assert_equal "https://github.com/user/repo", url
23
+ end
24
+
25
+ def test_parse_https_remote
26
+ url = RuVim::Gh::Link.github_url_from_remote("https://github.com/user/repo.git")
27
+ assert_equal "https://github.com/user/repo", url
28
+ end
29
+
30
+ def test_parse_https_remote_without_dot_git
31
+ url = RuVim::Gh::Link.github_url_from_remote("https://github.com/user/repo")
32
+ assert_equal "https://github.com/user/repo", url
33
+ end
34
+
35
+ def test_parse_non_github_remote_returns_nil
36
+ url = RuVim::Gh::Link.github_url_from_remote("git@gitlab.com:user/repo.git")
37
+ assert_nil url
38
+ end
39
+
40
+ def test_parse_empty_remote_returns_nil
41
+ url = RuVim::Gh::Link.github_url_from_remote("")
42
+ assert_nil url
43
+ end
44
+
45
+ # --- Link building ---
46
+
47
+ def test_build_link_single_line
48
+ link = RuVim::Gh::Link.build_url("https://github.com/user/repo", "main", "lib/foo.rb", 10)
49
+ assert_equal "https://github.com/user/repo/blob/main/lib/foo.rb#L10", link
50
+ end
51
+
52
+ def test_build_link_line_range
53
+ link = RuVim::Gh::Link.build_url("https://github.com/user/repo", "main", "lib/foo.rb", 10, 20)
54
+ assert_equal "https://github.com/user/repo/blob/main/lib/foo.rb#L10-L20", link
55
+ end
56
+
57
+ def test_build_link_same_start_end
58
+ link = RuVim::Gh::Link.build_url("https://github.com/user/repo", "main", "lib/foo.rb", 5, 5)
59
+ assert_equal "https://github.com/user/repo/blob/main/lib/foo.rb#L5", link
60
+ end
61
+
62
+ # --- OSC 52 ---
63
+
64
+ def test_osc52_escape_sequence
65
+ seq = RuVim::Gh::Link.osc52_copy_sequence("hello")
66
+ expected = "\e]52;c;#{["hello"].pack("m0")}\a"
67
+ assert_equal expected, seq
68
+ end
69
+
70
+ # --- Remote detection ---
71
+
72
+ def test_find_github_remote_with_specific_name
73
+ # This test runs in the actual ruvim repo
74
+ name, url = RuVim::Gh::Link.find_github_remote(Dir.pwd, "origin")
75
+ if url
76
+ assert_equal "origin", name
77
+ assert_match(%r{\Ahttps://github\.com/}, url)
78
+ else
79
+ skip "origin is not a GitHub remote in this repo"
80
+ end
81
+ end
82
+
83
+ def test_find_github_remote_auto_detect
84
+ name, url = RuVim::Gh::Link.find_github_remote(Dir.pwd)
85
+ if url
86
+ assert_kind_of String, name
87
+ assert_match(%r{\Ahttps://github\.com/}, url)
88
+ else
89
+ skip "No GitHub remote in this repo"
90
+ end
91
+ end
92
+
93
+ def test_find_github_remote_nonexistent_returns_nil
94
+ name, url = RuVim::Gh::Link.find_github_remote(Dir.pwd, "nonexistent_remote_xyz")
95
+ assert_nil name
96
+ assert_nil url
97
+ end
98
+
99
+ # --- Resolve warning ---
100
+
101
+ def test_resolve_returns_warning_when_file_differs
102
+ # file_differs_from_remote? returns true for non-existent remote ref
103
+ assert RuVim::Gh::Link.file_differs_from_remote?(Dir.pwd, "nonexistent_remote_xyz", "main", __FILE__)
104
+ end
105
+
106
+ # --- Ex command integration ---
107
+
108
+ # --- PR URL ---
109
+
110
+ def test_pr_search_url
111
+ url = RuVim::Gh::Link.pr_search_url("https://github.com/user/repo", "feature-branch")
112
+ assert_equal "https://github.com/user/repo/pulls?q=head:feature-branch", url
113
+ end
114
+
115
+ # --- Ex command integration ---
116
+
117
+ def test_gh_browse_listed_in_subcommands
118
+ @dispatcher.dispatch_ex(@editor, "gh")
119
+ assert_match(/browse/, @editor.message)
120
+ end
121
+
122
+ def test_gh_pr_listed_in_subcommands
123
+ @dispatcher.dispatch_ex(@editor, "gh")
124
+ assert_match(/pr/, @editor.message)
125
+ end
126
+
127
+ def test_gh_no_subcommand_shows_help
128
+ @dispatcher.dispatch_ex(@editor, "gh")
129
+ assert_match(/link/, @editor.message)
130
+ end
131
+
132
+ def test_gh_unknown_subcommand_runs_shell
133
+ executed = nil
134
+ fake_status = Struct.new(:exitstatus).new(0)
135
+ @editor.shell_executor = ->(cmd) { executed = cmd; fake_status }
136
+
137
+ @dispatcher.dispatch_ex(@editor, "gh issue list")
138
+
139
+ assert_equal "gh issue list", executed
140
+ end
141
+ end