ruvim 0.4.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 (113) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +53 -4
  3. data/README.md +15 -6
  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 +3 -2
  10. data/docs/command.md +81 -9
  11. data/docs/done.md +23 -0
  12. data/docs/spec.md +105 -19
  13. data/docs/todo.md +9 -0
  14. data/docs/tutorial.md +9 -1
  15. data/docs/vim_diff.md +13 -0
  16. data/ext/ruvim/extconf.rb +5 -0
  17. data/ext/ruvim/ruvim_ext.c +519 -0
  18. data/lib/ruvim/app.rb +217 -2778
  19. data/lib/ruvim/browser.rb +104 -0
  20. data/lib/ruvim/buffer.rb +39 -28
  21. data/lib/ruvim/command_invocation.rb +2 -2
  22. data/lib/ruvim/completion_manager.rb +708 -0
  23. data/lib/ruvim/dispatcher.rb +14 -8
  24. data/lib/ruvim/display_width.rb +91 -45
  25. data/lib/ruvim/editor.rb +64 -81
  26. data/lib/ruvim/ex_command_registry.rb +3 -1
  27. data/lib/ruvim/gh/link.rb +207 -0
  28. data/lib/ruvim/git/blame.rb +16 -6
  29. data/lib/ruvim/git/branch.rb +20 -5
  30. data/lib/ruvim/git/grep.rb +107 -0
  31. data/lib/ruvim/git/handler.rb +42 -1
  32. data/lib/ruvim/global_commands.rb +175 -35
  33. data/lib/ruvim/highlighter.rb +4 -13
  34. data/lib/ruvim/key_handler.rb +1510 -0
  35. data/lib/ruvim/keymap_manager.rb +7 -7
  36. data/lib/ruvim/lang/base.rb +5 -0
  37. data/lib/ruvim/lang/c.rb +116 -0
  38. data/lib/ruvim/lang/cpp.rb +107 -0
  39. data/lib/ruvim/lang/csv.rb +4 -1
  40. data/lib/ruvim/lang/diff.rb +2 -0
  41. data/lib/ruvim/lang/dockerfile.rb +36 -0
  42. data/lib/ruvim/lang/elixir.rb +85 -0
  43. data/lib/ruvim/lang/erb.rb +30 -0
  44. data/lib/ruvim/lang/go.rb +83 -0
  45. data/lib/ruvim/lang/html.rb +34 -0
  46. data/lib/ruvim/lang/javascript.rb +83 -0
  47. data/lib/ruvim/lang/json.rb +6 -0
  48. data/lib/ruvim/lang/lua.rb +76 -0
  49. data/lib/ruvim/lang/makefile.rb +36 -0
  50. data/lib/ruvim/lang/markdown.rb +3 -4
  51. data/lib/ruvim/lang/ocaml.rb +77 -0
  52. data/lib/ruvim/lang/perl.rb +91 -0
  53. data/lib/ruvim/lang/python.rb +85 -0
  54. data/lib/ruvim/lang/registry.rb +102 -0
  55. data/lib/ruvim/lang/ruby.rb +7 -0
  56. data/lib/ruvim/lang/rust.rb +95 -0
  57. data/lib/ruvim/lang/scheme.rb +5 -0
  58. data/lib/ruvim/lang/sh.rb +76 -0
  59. data/lib/ruvim/lang/sql.rb +52 -0
  60. data/lib/ruvim/lang/toml.rb +36 -0
  61. data/lib/ruvim/lang/tsv.rb +4 -1
  62. data/lib/ruvim/lang/typescript.rb +53 -0
  63. data/lib/ruvim/lang/yaml.rb +62 -0
  64. data/lib/ruvim/rich_view/table_renderer.rb +3 -3
  65. data/lib/ruvim/rich_view.rb +14 -7
  66. data/lib/ruvim/screen.rb +126 -72
  67. data/lib/ruvim/stream/file_load.rb +85 -0
  68. data/lib/ruvim/stream/follow.rb +40 -0
  69. data/lib/ruvim/stream/git.rb +43 -0
  70. data/lib/ruvim/stream/run.rb +74 -0
  71. data/lib/ruvim/stream/stdin.rb +55 -0
  72. data/lib/ruvim/stream.rb +35 -0
  73. data/lib/ruvim/stream_mixer.rb +394 -0
  74. data/lib/ruvim/terminal.rb +18 -4
  75. data/lib/ruvim/text_metrics.rb +84 -65
  76. data/lib/ruvim/version.rb +1 -1
  77. data/lib/ruvim/window.rb +5 -5
  78. data/lib/ruvim.rb +23 -6
  79. data/test/app_command_test.rb +382 -0
  80. data/test/app_completion_test.rb +43 -19
  81. data/test/app_dot_repeat_test.rb +27 -3
  82. data/test/app_ex_command_test.rb +154 -0
  83. data/test/app_motion_test.rb +13 -12
  84. data/test/app_register_test.rb +2 -1
  85. data/test/app_scenario_test.rb +15 -10
  86. data/test/app_startup_test.rb +70 -27
  87. data/test/app_text_object_test.rb +2 -1
  88. data/test/app_unicode_behavior_test.rb +3 -2
  89. data/test/browser_test.rb +88 -0
  90. data/test/buffer_test.rb +24 -0
  91. data/test/cli_test.rb +63 -0
  92. data/test/command_invocation_test.rb +33 -0
  93. data/test/config_dsl_test.rb +47 -0
  94. data/test/dispatcher_test.rb +74 -4
  95. data/test/ex_command_registry_test.rb +106 -0
  96. data/test/follow_test.rb +20 -21
  97. data/test/gh_link_test.rb +141 -0
  98. data/test/git_blame_test.rb +96 -17
  99. data/test/git_grep_test.rb +64 -0
  100. data/test/highlighter_test.rb +125 -0
  101. data/test/indent_test.rb +137 -0
  102. data/test/input_screen_integration_test.rb +1 -1
  103. data/test/keyword_chars_test.rb +85 -0
  104. data/test/lang_test.rb +634 -0
  105. data/test/markdown_renderer_test.rb +5 -5
  106. data/test/on_save_hook_test.rb +12 -8
  107. data/test/render_snapshot_test.rb +78 -0
  108. data/test/rich_view_test.rb +42 -42
  109. data/test/run_command_test.rb +307 -0
  110. data/test/screen_test.rb +68 -5
  111. data/test/stream_test.rb +165 -0
  112. data/test/window_test.rb +59 -0
  113. metadata +52 -2
data/test/cli_test.rb CHANGED
@@ -176,4 +176,67 @@ class CLITest < Minitest::Test
176
176
  assert_equal 2, code
177
177
  assert_match(/config file not found/, err.string)
178
178
  end
179
+
180
+ def test_parse_double_dash_stops_options
181
+ opts = RuVim::CLI.parse(["--", "-R", "--help"])
182
+ assert_equal ["-R", "--help"], opts.files
183
+ assert_equal false, opts.readonly
184
+ assert_equal false, opts.show_help
185
+ end
186
+
187
+ def test_parse_unknown_option_raises
188
+ assert_raises(RuVim::CLI::ParseError) do
189
+ RuVim::CLI.parse(["--unknown-flag"])
190
+ end
191
+ end
192
+
193
+ def test_parse_startuptime_missing_arg_raises
194
+ assert_raises(RuVim::CLI::ParseError) do
195
+ RuVim::CLI.parse(["--startuptime"])
196
+ end
197
+ end
198
+
199
+ def test_parse_cmd_missing_arg_raises
200
+ assert_raises(RuVim::CLI::ParseError) do
201
+ RuVim::CLI.parse(["--cmd"])
202
+ end
203
+ end
204
+
205
+ def test_parse_q_missing_arg_raises
206
+ assert_raises(RuVim::CLI::ParseError) do
207
+ RuVim::CLI.parse(["-q"])
208
+ end
209
+ end
210
+
211
+ def test_parse_u_missing_arg_raises
212
+ assert_raises(RuVim::CLI::ParseError) do
213
+ RuVim::CLI.parse(["-u"])
214
+ end
215
+ end
216
+
217
+ def test_parse_c_missing_arg_raises
218
+ assert_raises(RuVim::CLI::ParseError) do
219
+ RuVim::CLI.parse(["-c"])
220
+ end
221
+ end
222
+
223
+ def test_parse_combined_u_value
224
+ opts = RuVim::CLI.parse(["-u/tmp/myrc.rb"])
225
+ assert_equal "/tmp/myrc.rb", opts.config_path
226
+ end
227
+
228
+ def test_parse_verbose_without_level
229
+ opts = RuVim::CLI.parse(["--verbose"])
230
+ assert_equal 1, opts.verbose_level
231
+ end
232
+
233
+ def test_parse_multiple_files
234
+ opts = RuVim::CLI.parse(["a.txt", "b.txt", "c.txt"])
235
+ assert_equal ["a.txt", "b.txt", "c.txt"], opts.files
236
+ end
237
+
238
+ def test_parse_plus_ex_command
239
+ opts = RuVim::CLI.parse(["+set number", "file.txt"])
240
+ assert_equal [{ type: :ex, value: "set number" }], opts.startup_actions
241
+ end
179
242
  end
@@ -0,0 +1,33 @@
1
+ require_relative "test_helper"
2
+
3
+ class CommandInvocationTest < Minitest::Test
4
+ def test_bang_defaults_to_false
5
+ inv = RuVim::CommandInvocation.new(id: "test")
6
+ assert_equal false, inv.bang
7
+ end
8
+
9
+ def test_bang_true
10
+ inv = RuVim::CommandInvocation.new(id: "test", bang: true)
11
+ assert_equal true, inv.bang
12
+ end
13
+
14
+ def test_bang_false
15
+ inv = RuVim::CommandInvocation.new(id: "test", bang: false)
16
+ assert_equal false, inv.bang
17
+ end
18
+
19
+ def test_argv_defaults_to_empty_array
20
+ inv = RuVim::CommandInvocation.new(id: "test")
21
+ assert_equal [], inv.argv
22
+ end
23
+
24
+ def test_kwargs_defaults_to_empty_hash
25
+ inv = RuVim::CommandInvocation.new(id: "test")
26
+ assert_equal({}, inv.kwargs)
27
+ end
28
+
29
+ def test_count_defaults_to_nil
30
+ inv = RuVim::CommandInvocation.new(id: "test")
31
+ assert_nil inv.count
32
+ end
33
+ end
@@ -162,4 +162,51 @@ class ConfigDSLTest < Minitest::Test
162
162
  dsl.set("tabstop=4")
163
163
  assert_equal 4, editor.get_option("tabstop")
164
164
  end
165
+
166
+ def test_ex_command_registers_and_allows_re_register
167
+ dsl = RuVim::ConfigDSL.new(
168
+ command_registry: @command_registry,
169
+ ex_registry: RuVim::ExCommandRegistry.instance,
170
+ keymaps: @keymaps,
171
+ command_host: RuVim::GlobalCommands.instance
172
+ )
173
+ saved_specs = RuVim::ExCommandRegistry.instance.instance_variable_get(:@specs).dup
174
+ saved_lookup = RuVim::ExCommandRegistry.instance.instance_variable_get(:@lookup).dup
175
+
176
+ dsl.ex_command("testusercmd", desc: "test") { |_ctx, **| }
177
+ assert RuVim::ExCommandRegistry.instance.registered?("testusercmd")
178
+
179
+ # Re-registering should not raise (unregisters first)
180
+ dsl.ex_command("testusercmd", desc: "test v2") { |_ctx, **| }
181
+ assert RuVim::ExCommandRegistry.instance.registered?("testusercmd")
182
+ ensure
183
+ RuVim::ExCommandRegistry.instance.instance_variable_set(:@specs, saved_specs)
184
+ RuVim::ExCommandRegistry.instance.instance_variable_set(:@lookup, saved_lookup)
185
+ end
186
+
187
+ def test_setlocal_sets_local_scope
188
+ editor = fresh_editor
189
+ dsl = RuVim::ConfigDSL.new(
190
+ command_registry: @command_registry,
191
+ ex_registry: @ex_registry,
192
+ keymaps: @keymaps,
193
+ command_host: RuVim::GlobalCommands.instance,
194
+ editor: editor
195
+ )
196
+ dsl.setlocal("number")
197
+ assert editor.get_option("number")
198
+ end
199
+
200
+ def test_setglobal_sets_global_scope
201
+ editor = fresh_editor
202
+ dsl = RuVim::ConfigDSL.new(
203
+ command_registry: @command_registry,
204
+ ex_registry: @ex_registry,
205
+ keymaps: @keymaps,
206
+ command_host: RuVim::GlobalCommands.instance,
207
+ editor: editor
208
+ )
209
+ dsl.setglobal("ignorecase")
210
+ assert editor.get_option("ignorecase")
211
+ end
165
212
  end
@@ -119,19 +119,89 @@ class DispatcherTest < Minitest::Test
119
119
  assert_includes body, "42"
120
120
  end
121
121
 
122
- def test_dispatch_ex_shell_captures_stdout_and_stderr_into_virtual_buffer
122
+ def test_dispatch_ex_shell_uses_shell_executor_when_available
123
+ executed_command = nil
124
+ fake_status = Struct.new(:exitstatus).new(0)
125
+ @editor.shell_executor = ->(cmd) { executed_command = cmd; fake_status }
126
+
127
+ @dispatcher.dispatch_ex(@editor, "!echo hello")
128
+
129
+ assert_equal "echo hello", executed_command
130
+ assert_equal "shell exit 0", @editor.message
131
+ end
132
+
133
+ def test_dispatch_ex_shell_falls_back_to_capture_without_executor
134
+ @editor.shell_executor = nil
123
135
  @dispatcher.dispatch_ex(@editor, "!echo out; echo err 1>&2")
124
136
 
125
137
  assert_equal "[Shell Output]", @editor.message
126
138
  assert_equal :help, @editor.current_buffer.kind
127
139
  body = @editor.current_buffer.lines.join("\n")
128
- assert_includes body, "[command]"
129
- assert_includes body, "echo out; echo err 1>&2"
130
140
  assert_includes body, "[stdout]"
131
141
  assert_includes body, "out"
132
142
  assert_includes body, "[stderr]"
133
143
  assert_includes body, "err"
134
- assert_includes body, "[status]"
144
+ end
145
+
146
+ def test_dispatch_ex_read_file_inserts_after_cursor_line
147
+ @editor.materialize_intro_buffer!
148
+ Dir.mktmpdir do |dir|
149
+ path = File.join(dir, "data.txt")
150
+ File.write(path, "aaa\nbbb\n")
151
+ @editor.current_buffer.replace_all_lines!(%w[line1 line2 line3])
152
+ @editor.current_window.cursor_y = 1 # on line2
153
+
154
+ @dispatcher.dispatch_ex(@editor, "r #{path}")
155
+
156
+ assert_equal %w[line1 line2 aaa bbb line3], @editor.current_buffer.lines
157
+ end
158
+ end
159
+
160
+ def test_dispatch_ex_read_with_range_inserts_after_specified_line
161
+ @editor.materialize_intro_buffer!
162
+ Dir.mktmpdir do |dir|
163
+ path = File.join(dir, "data.txt")
164
+ File.write(path, "inserted\n")
165
+ @editor.current_buffer.replace_all_lines!(%w[line1 line2 line3])
166
+
167
+ @dispatcher.dispatch_ex(@editor, "2r #{path}")
168
+
169
+ assert_equal %w[line1 line2 inserted line3], @editor.current_buffer.lines
170
+ end
171
+ end
172
+
173
+ def test_dispatch_ex_read_shell_command_inserts_output
174
+ @editor.materialize_intro_buffer!
175
+ @editor.current_buffer.replace_all_lines!(%w[line1 line2])
176
+ @editor.current_window.cursor_y = 0
177
+
178
+ @dispatcher.dispatch_ex(@editor, "r !echo hello")
179
+
180
+ assert_equal %w[line1 hello line2], @editor.current_buffer.lines
181
+ end
182
+
183
+ def test_dispatch_ex_write_to_shell_command
184
+ @editor.materialize_intro_buffer!
185
+ @editor.current_buffer.replace_all_lines!(%w[hello world])
186
+
187
+ Dir.mktmpdir do |dir|
188
+ outfile = File.join(dir, "out.txt")
189
+ @dispatcher.dispatch_ex(@editor, "w !cat > #{outfile}")
190
+
191
+ assert_equal "hello\nworld\n", File.read(outfile)
192
+ end
193
+ end
194
+
195
+ def test_dispatch_ex_write_range_to_shell_command
196
+ @editor.materialize_intro_buffer!
197
+ @editor.current_buffer.replace_all_lines!(%w[aaa bbb ccc])
198
+
199
+ Dir.mktmpdir do |dir|
200
+ outfile = File.join(dir, "out.txt")
201
+ @dispatcher.dispatch_ex(@editor, "2,3w !cat > #{outfile}")
202
+
203
+ assert_equal "bbb\nccc\n", File.read(outfile)
204
+ end
135
205
  end
136
206
 
137
207
  def test_dispatch_ex_set_commands
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class ExCommandRegistryTest < Minitest::Test
6
+ def setup
7
+ @registry = RuVim::ExCommandRegistry.instance
8
+ @saved_specs = @registry.instance_variable_get(:@specs).dup
9
+ @saved_lookup = @registry.instance_variable_get(:@lookup).dup
10
+ end
11
+
12
+ def teardown
13
+ @registry.instance_variable_set(:@specs, @saved_specs)
14
+ @registry.instance_variable_set(:@lookup, @saved_lookup)
15
+ end
16
+
17
+ def test_register_and_resolve
18
+ @registry.clear!
19
+ spec = @registry.register("testcmd", call: ->(_ctx) {}, desc: "test")
20
+ assert_equal "testcmd", spec.name
21
+
22
+ resolved = @registry.resolve("testcmd")
23
+ assert_equal spec, resolved
24
+ end
25
+
26
+ def test_register_with_aliases
27
+ @registry.clear!
28
+ spec = @registry.register("testcmd", call: ->(_ctx) {}, aliases: ["tc", "tcmd"])
29
+
30
+ assert_equal @registry.resolve("tc"), spec
31
+ assert_equal @registry.resolve("tcmd"), spec
32
+ end
33
+
34
+ def test_register_duplicate_raises
35
+ @registry.clear!
36
+ @registry.register("testcmd", call: ->(_ctx) {})
37
+
38
+ assert_raises(RuVim::CommandError) do
39
+ @registry.register("testcmd", call: ->(_ctx) {})
40
+ end
41
+ end
42
+
43
+ def test_register_alias_collision_raises
44
+ @registry.clear!
45
+ @registry.register("cmd1", call: ->(_ctx) {}, aliases: ["shared"])
46
+
47
+ assert_raises(RuVim::CommandError) do
48
+ @registry.register("cmd2", call: ->(_ctx) {}, aliases: ["shared"])
49
+ end
50
+ end
51
+
52
+ def test_resolve_returns_nil_for_unknown
53
+ @registry.clear!
54
+ assert_nil @registry.resolve("nonexistent")
55
+ end
56
+
57
+ def test_fetch_raises_for_unknown
58
+ @registry.clear!
59
+ assert_raises(RuVim::CommandError) do
60
+ @registry.fetch("nonexistent")
61
+ end
62
+ end
63
+
64
+ def test_fetch_returns_spec
65
+ @registry.clear!
66
+ spec = @registry.register("testcmd", call: ->(_ctx) {})
67
+ assert_equal spec, @registry.fetch("testcmd")
68
+ end
69
+
70
+ def test_registered?
71
+ @registry.clear!
72
+ refute @registry.registered?("testcmd")
73
+ @registry.register("testcmd", call: ->(_ctx) {})
74
+ assert @registry.registered?("testcmd")
75
+ end
76
+
77
+ def test_all_returns_sorted
78
+ @registry.clear!
79
+ @registry.register("beta", call: ->(_ctx) {})
80
+ @registry.register("alpha", call: ->(_ctx) {})
81
+ names = @registry.all.map(&:name)
82
+ assert_equal ["alpha", "beta"], names
83
+ end
84
+
85
+ def test_unregister
86
+ @registry.clear!
87
+ @registry.register("testcmd", call: ->(_ctx) {}, aliases: ["tc"])
88
+
89
+ removed = @registry.unregister("testcmd")
90
+ assert_equal "testcmd", removed.name
91
+ assert_nil @registry.resolve("testcmd")
92
+ assert_nil @registry.resolve("tc")
93
+ end
94
+
95
+ def test_unregister_unknown_returns_nil
96
+ @registry.clear!
97
+ assert_nil @registry.unregister("nonexistent")
98
+ end
99
+
100
+ def test_clear!
101
+ @registry.clear!
102
+ @registry.register("testcmd", call: ->(_ctx) {})
103
+ @registry.clear!
104
+ assert_equal [], @registry.all
105
+ end
106
+ end
data/test/follow_test.rb CHANGED
@@ -13,26 +13,29 @@ class FollowTest < Minitest::Test
13
13
  @app = RuVim::App.new(path: @path, clean: true)
14
14
  @editor = @app.instance_variable_get(:@editor)
15
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)
16
18
  end
17
19
 
18
20
  def cleanup_follow_app
19
21
  return unless @app
20
22
 
21
- watchers = @app.instance_variable_get(:@follow_watchers)
22
- watchers.each_value { |w| w.stop rescue nil }
23
- watchers.clear
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
24
28
  @tmpfile&.close!
25
29
  end
26
30
 
27
31
  def test_follow_starts_on_file_buffer
28
32
  create_follow_app
29
33
  @dispatcher.dispatch_ex(@editor, "follow")
30
- watchers = @app.instance_variable_get(:@follow_watchers)
31
34
  buf = @editor.current_buffer
32
35
 
33
36
  assert !@editor.message_error?, "Unexpected error: #{@editor.message}"
34
- assert_equal :live, buf.stream_state
35
- assert watchers.key?(buf.id)
37
+ assert_equal :live, buf.stream.state
38
+ assert buf.stream.watcher
36
39
  assert_includes @editor.message.to_s, "[follow]"
37
40
  ensure
38
41
  cleanup_follow_app
@@ -42,10 +45,10 @@ class FollowTest < Minitest::Test
42
45
  create_follow_app
43
46
  @dispatcher.dispatch_ex(@editor, "follow")
44
47
  buf = @editor.current_buffer
45
- assert_equal :live, buf.stream_state
48
+ assert_equal :live, buf.stream.state
46
49
 
47
- @app.send(:handle_key, :ctrl_c)
48
- assert_nil buf.stream_state
50
+ @key_handler.handle(:ctrl_c)
51
+ assert_nil buf.stream
49
52
  assert_includes @editor.message.to_s, "stopped"
50
53
  ensure
51
54
  cleanup_follow_app
@@ -55,12 +58,10 @@ class FollowTest < Minitest::Test
55
58
  create_follow_app
56
59
  @dispatcher.dispatch_ex(@editor, "follow")
57
60
  buf = @editor.current_buffer
58
- assert_equal :live, buf.stream_state
61
+ assert_equal :live, buf.stream.state
59
62
 
60
63
  @dispatcher.dispatch_ex(@editor, "follow")
61
- watchers = @app.instance_variable_get(:@follow_watchers)
62
- assert_nil buf.stream_state
63
- refute watchers.key?(buf.id)
64
+ assert_nil buf.stream
64
65
  assert_includes @editor.message.to_s, "stopped"
65
66
  ensure
66
67
  cleanup_follow_app
@@ -101,7 +102,7 @@ class FollowTest < Minitest::Test
101
102
 
102
103
  @dispatcher.dispatch_ex(@editor, "follow")
103
104
  assert_includes @editor.message.to_s, "unsaved changes"
104
- assert_nil buf.stream_state
105
+ assert_nil buf.stream
105
106
  ensure
106
107
  cleanup_follow_app
107
108
  end
@@ -129,7 +130,7 @@ class FollowTest < Minitest::Test
129
130
  File.open(@path, "a") { |f| f.write("line3\nline4\n") }
130
131
 
131
132
  assert_eventually(timeout: 3) do
132
- @app.send(:drain_stream_events!)
133
+ @stream_mixer.drain_events!
133
134
  buf.line_count > 3
134
135
  end
135
136
 
@@ -153,7 +154,7 @@ class FollowTest < Minitest::Test
153
154
  File.open(@path, "a") { |f| f.write("line3\n") }
154
155
 
155
156
  assert_eventually(timeout: 3) do
156
- @app.send(:drain_stream_events!)
157
+ @stream_mixer.drain_events!
157
158
  buf.line_count > 2
158
159
  end
159
160
 
@@ -170,16 +171,14 @@ class FollowTest < Minitest::Test
170
171
 
171
172
  app = RuVim::App.new(paths: [tmp1.path, tmp2.path], follow: true, clean: true)
172
173
  editor = app.instance_variable_get(:@editor)
173
- watchers = app.instance_variable_get(:@follow_watchers)
174
-
175
174
  bufs = editor.buffers.values.select(&:file_buffer?)
176
175
  assert_equal 2, bufs.size
177
176
  bufs.each do |buf|
178
- assert_equal :live, buf.stream_state, "#{buf.display_name} should be in follow mode"
179
- assert watchers.key?(buf.id), "#{buf.display_name} should have a watcher"
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"
180
179
  end
181
180
  ensure
182
- watchers&.each_value { |w| w.stop rescue nil }
181
+ bufs&.each { |b| b.stream&.stop! rescue nil }
183
182
  tmp1&.close!
184
183
  tmp2&.close!
185
184
  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