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
@@ -0,0 +1,708 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "open3"
6
+
7
+ module RuVim
8
+ class CompletionManager
9
+ def initialize(editor:, terminal:, verbose_logger: nil)
10
+ @editor = editor
11
+ @terminal = terminal
12
+ @verbose_logger = verbose_logger
13
+ @cmdline_history = Hash.new { |h, k| h[k] = [] }
14
+ @cmdline_history_index = nil
15
+ @cmdline_completion = nil
16
+ @insert_completion = nil
17
+ @incsearch_preview = nil
18
+ end
19
+
20
+ # --- Command-line history ---
21
+
22
+ def push_history(prefix, line)
23
+ return if line.empty?
24
+
25
+ text = line
26
+
27
+ hist = @cmdline_history[prefix]
28
+ hist.delete(text)
29
+ hist << text
30
+ hist.shift while hist.length > 100
31
+ @cmdline_history_index = nil
32
+ end
33
+
34
+ def reset_history_index!
35
+ @cmdline_history_index = nil
36
+ end
37
+
38
+ def load_history!
39
+ path = history_file_path
40
+ return unless path
41
+ return unless File.file?(path)
42
+
43
+ raw = File.read(path)
44
+ data = JSON.parse(raw)
45
+ return unless data.is_a?(Hash)
46
+
47
+ loaded = Hash.new { |h, k| h[k] = [] }
48
+ data.each do |prefix, items|
49
+ next unless [":", "/", "?"].include?(prefix)
50
+ next unless items.is_a?(Array)
51
+
52
+ hist = loaded[prefix]
53
+ items.each do |item|
54
+ next if item.empty?
55
+
56
+ hist.delete(item)
57
+ hist << item
58
+ end
59
+ hist.shift while hist.length > 100
60
+ end
61
+ @cmdline_history = loaded
62
+ rescue StandardError => e
63
+ verbose_log(1, "history load error: #{e.message}")
64
+ end
65
+
66
+ def save_history!
67
+ path = history_file_path
68
+ return unless path
69
+
70
+ payload = {
71
+ ":" => Array(@cmdline_history[":"]).map(&:to_s).last(100),
72
+ "/" => Array(@cmdline_history["/"]).map(&:to_s).last(100),
73
+ "?" => Array(@cmdline_history["?"]).map(&:to_s).last(100)
74
+ }
75
+
76
+ FileUtils.mkdir_p(File.dirname(path))
77
+ tmp = "#{path}.tmp"
78
+ File.write(tmp, JSON.pretty_generate(payload) + "\n")
79
+ File.rename(tmp, path)
80
+ rescue StandardError => e
81
+ verbose_log(1, "history save error: #{e.message}")
82
+ end
83
+
84
+ def history_move(delta)
85
+ cmd = @editor.command_line
86
+ hist = @cmdline_history[cmd.prefix]
87
+ return if hist.empty?
88
+
89
+ @cmdline_history_index =
90
+ if @cmdline_history_index.nil?
91
+ delta.negative? ? hist.length - 1 : hist.length
92
+ else
93
+ @cmdline_history_index + delta
94
+ end
95
+
96
+ @cmdline_history_index = [[@cmdline_history_index, 0].max, hist.length].min
97
+ if @cmdline_history_index == hist.length
98
+ cmd.replace_text("")
99
+ else
100
+ cmd.replace_text(hist[@cmdline_history_index])
101
+ end
102
+ update_incsearch_preview_if_needed
103
+ end
104
+
105
+ # --- Command-line completion ---
106
+
107
+ def command_line_complete
108
+ cmd = @editor.command_line
109
+ return unless cmd.prefix == ":"
110
+
111
+ ctx = ex_completion_context(cmd)
112
+ return unless ctx
113
+
114
+ matches = reusable_command_line_completion_matches(cmd, ctx) || ex_completion_candidates(ctx)
115
+ case matches.length
116
+ when 0
117
+ clear_command_line_completion
118
+ @editor.echo("No completion")
119
+ when 1
120
+ clear_command_line_completion
121
+ cmd.replace_span(ctx[:token_start], ctx[:token_end], matches.first)
122
+ else
123
+ apply_wildmode_completion(cmd, ctx, matches)
124
+ end
125
+ update_incsearch_preview_if_needed
126
+ end
127
+
128
+ def clear_command_line_completion
129
+ @cmdline_completion = nil
130
+ end
131
+
132
+ # --- Insert completion ---
133
+
134
+ def clear_insert_completion
135
+ @insert_completion = nil
136
+ end
137
+
138
+ def insert_complete(direction)
139
+ state = ensure_insert_completion_state
140
+ return unless state
141
+
142
+ matches = state[:matches]
143
+ if matches.empty?
144
+ @editor.echo("No completion")
145
+ return
146
+ end
147
+
148
+ if state[:index].nil? && insert_completion_noselect? && matches.length > 1
149
+ show_insert_completion_menu(matches, selected: nil)
150
+ state[:index] = :pending_select
151
+ return
152
+ end
153
+
154
+ if state[:index].nil? && insert_completion_noinsert?
155
+ preview_idx = direction.positive? ? 0 : matches.length - 1
156
+ state[:index] = :pending_insert
157
+ state[:pending_index] = preview_idx
158
+ show_insert_completion_menu(matches, selected: preview_idx, current: matches[preview_idx])
159
+ return
160
+ end
161
+
162
+ idx = state[:index]
163
+ idx = nil if idx == :pending_select
164
+ if idx == :pending_insert
165
+ idx = state.delete(:pending_index) || (direction.positive? ? 0 : matches.length - 1)
166
+ else
167
+ idx = idx.nil? ? (direction.positive? ? 0 : matches.length - 1) : (idx + direction) % matches.length
168
+ end
169
+ replacement = matches[idx]
170
+
171
+ end_col = state[:current_end_col]
172
+ start_col = state[:start_col]
173
+ @editor.current_buffer.delete_span(state[:row], start_col, state[:row], end_col)
174
+ _y, new_x = @editor.current_buffer.insert_text(state[:row], start_col, replacement)
175
+ @editor.current_window.cursor_y = state[:row]
176
+ @editor.current_window.cursor_x = new_x
177
+ state[:index] = idx
178
+ state[:current_end_col] = start_col + replacement.length
179
+ if matches.length == 1
180
+ @editor.echo(replacement)
181
+ else
182
+ show_insert_completion_menu(matches, selected: idx, current: replacement)
183
+ end
184
+ rescue StandardError => e
185
+ @editor.echo_error("Completion error: #{e.message}")
186
+ clear_insert_completion
187
+ end
188
+
189
+ # --- Incremental search preview ---
190
+
191
+ def incsearch_enabled?
192
+ return false unless @editor.command_line_active?
193
+ return false unless ["/", "?"].include?(@editor.command_line.prefix)
194
+
195
+ !!@editor.effective_option("incsearch")
196
+ end
197
+
198
+ def update_incsearch_preview_if_needed
199
+ return unless incsearch_enabled?
200
+
201
+ cmd = @editor.command_line
202
+ ensure_incsearch_preview_origin!(direction: (cmd.prefix == "/" ? :forward : :backward))
203
+ pattern = cmd.text
204
+ if pattern.empty?
205
+ clear_incsearch_preview_state(apply: false)
206
+ return
207
+ end
208
+
209
+ buf = @editor.current_buffer
210
+ win = @editor.current_window
211
+ origin = @incsearch_preview[:origin]
212
+ tmp_window = RuVim::Window.new(id: -1, buffer_id: buf.id)
213
+ tmp_window.cursor_y = origin[:row]
214
+ tmp_window.cursor_x = origin[:col]
215
+ regex = GlobalCommands.instance.send(:compile_search_regex, pattern, editor: @editor, window: win, buffer: buf)
216
+ match = GlobalCommands.instance.send(:find_next_match, buf, tmp_window, regex, direction: @incsearch_preview[:direction])
217
+ if match
218
+ win.cursor_y = match[:row]
219
+ win.cursor_x = match[:col]
220
+ win.clamp_to_buffer(buf)
221
+ end
222
+ @incsearch_preview[:active] = true
223
+ rescue RuVim::CommandError, RegexpError
224
+ # Keep editing command-line without forcing an error flash on every keystroke.
225
+ end
226
+
227
+ def cancel_incsearch_preview_if_any
228
+ clear_incsearch_preview_state(apply: false)
229
+ end
230
+
231
+ def clear_incsearch_preview_state(apply:)
232
+ return unless @incsearch_preview
233
+
234
+ if !apply && @incsearch_preview[:origin]
235
+ @editor.jump_to_location(@incsearch_preview[:origin])
236
+ end
237
+ @incsearch_preview = nil
238
+ end
239
+
240
+ # --- Keyword helpers ---
241
+
242
+ def trailing_keyword_fragment(prefix_text, window, buffer)
243
+ cls = keyword_char_class(window, buffer)
244
+ prefix_text.to_s[/[#{cls}]+\z/]
245
+ rescue RegexpError
246
+ prefix_text.to_s[/[[:alnum:]_]+\z/]
247
+ end
248
+
249
+ private
250
+
251
+ def verbose_log(level, message)
252
+ @verbose_logger&.call(level, message)
253
+ end
254
+
255
+ def history_file_path
256
+ xdg_state_home = ENV["XDG_STATE_HOME"].to_s
257
+ if !xdg_state_home.empty?
258
+ return File.join(xdg_state_home, "ruvim", "history.json")
259
+ end
260
+
261
+ home = ENV["HOME"].to_s
262
+ return nil if home.empty?
263
+
264
+ File.join(home, ".ruvim", "history.json")
265
+ end
266
+
267
+ def reusable_command_line_completion_matches(cmd, ctx)
268
+ state = @cmdline_completion
269
+ return nil unless state
270
+ return nil unless state[:prefix] == cmd.prefix
271
+ return nil unless state[:kind] == ctx[:kind]
272
+ return nil unless state[:command] == ctx[:command]
273
+ return nil unless state[:arg_index] == ctx[:arg_index]
274
+ return nil unless state[:token_start] == ctx[:token_start]
275
+
276
+ before_text = cmd.text[0...ctx[:token_start]].to_s
277
+ after_text = cmd.text[ctx[:token_end]..].to_s
278
+ return nil unless state[:before_text] == before_text
279
+ return nil unless state[:after_text] == after_text
280
+
281
+ matches = Array(state[:matches]).map(&:to_s)
282
+ return nil if matches.empty?
283
+
284
+ current_token = cmd.text[ctx[:token_start]...ctx[:token_end]].to_s
285
+ return nil unless current_token.empty? || matches.include?(current_token) || common_prefix(matches).start_with?(current_token) || current_token.start_with?(common_prefix(matches))
286
+
287
+ matches
288
+ end
289
+
290
+ def apply_wildmode_completion(cmd, ctx, matches)
291
+ mode_steps = wildmode_steps
292
+ mode_steps = [:full] if mode_steps.empty?
293
+ state = @cmdline_completion
294
+ before_text = cmd.text[0...ctx[:token_start]].to_s
295
+ after_text = cmd.text[ctx[:token_end]..].to_s
296
+ same = state &&
297
+ state[:prefix] == cmd.prefix &&
298
+ state[:kind] == ctx[:kind] &&
299
+ state[:command] == ctx[:command] &&
300
+ state[:arg_index] == ctx[:arg_index] &&
301
+ state[:token_start] == ctx[:token_start] &&
302
+ state[:before_text] == before_text &&
303
+ state[:after_text] == after_text &&
304
+ state[:matches] == matches
305
+ unless same
306
+ state = {
307
+ prefix: cmd.prefix,
308
+ kind: ctx[:kind],
309
+ command: ctx[:command],
310
+ arg_index: ctx[:arg_index],
311
+ token_start: ctx[:token_start],
312
+ before_text: before_text,
313
+ after_text: after_text,
314
+ matches: matches.dup,
315
+ step_index: -1,
316
+ full_index: nil
317
+ }
318
+ end
319
+
320
+ state[:step_index] += 1
321
+ step = mode_steps[state[:step_index] % mode_steps.length]
322
+ case step
323
+ when :longest
324
+ pref = common_prefix(matches)
325
+ cmd.replace_span(ctx[:token_start], ctx[:token_end], pref) if pref.length > ctx[:prefix].length
326
+ when :list
327
+ show_command_line_completion_menu(matches, selected: state[:full_index], force: true)
328
+ when :full
329
+ state[:full_index] = state[:full_index] ? (state[:full_index] + 1) % matches.length : 0
330
+ cmd.replace_span(ctx[:token_start], ctx[:token_end], matches[state[:full_index]])
331
+ show_command_line_completion_menu(matches, selected: state[:full_index], force: false)
332
+ else
333
+ pref = common_prefix(matches)
334
+ cmd.replace_span(ctx[:token_start], ctx[:token_end], pref) if pref.length > ctx[:prefix].length
335
+ end
336
+
337
+ @cmdline_completion = state
338
+ end
339
+
340
+ def wildmode_steps
341
+ raw = @editor.effective_option("wildmode").to_s
342
+ return [:full] if raw.empty?
343
+
344
+ raw.split(",").flat_map do |tok|
345
+ tok.split(":").map do |part|
346
+ case part.strip.downcase
347
+ when "longest" then :longest
348
+ when "list" then :list
349
+ when "full" then :full
350
+ end
351
+ end
352
+ end.compact
353
+ end
354
+
355
+ def show_command_line_completion_menu(matches, selected:, force:)
356
+ return unless force || @editor.effective_option("wildmenu")
357
+
358
+ items = matches.each_with_index.map do |m, i|
359
+ idx = i
360
+ idx == selected ? "[#{m}]" : m
361
+ end
362
+ @editor.echo(compose_command_line_completion_menu(items))
363
+ end
364
+
365
+ def compose_command_line_completion_menu(items)
366
+ parts = Array(items).map(&:to_s)
367
+ return "" if parts.empty?
368
+
369
+ width = command_line_completion_menu_width
370
+ width = [width.to_i, 1].max
371
+ out = +""
372
+ shown = 0
373
+
374
+ parts.each_with_index do |item, idx|
375
+ token = shown.zero? ? item : " #{item}"
376
+ if out.empty? && token.length > width
377
+ out = token[0, width]
378
+ shown = 1
379
+ break
380
+ end
381
+ break if out.length + token.length > width
382
+
383
+ out << token
384
+ shown = idx + 1
385
+ end
386
+
387
+ if shown < parts.length
388
+ ellipsis = (out.empty? ? "..." : " ...")
389
+ if out.length + ellipsis.length <= width
390
+ out << ellipsis
391
+ elsif width >= 3
392
+ out = out[0, width - 3] + "..."
393
+ else
394
+ out = "." * width
395
+ end
396
+ end
397
+
398
+ out
399
+ end
400
+
401
+ def command_line_completion_menu_width
402
+ return 80 unless defined?(@terminal) && @terminal && @terminal.respond_to?(:winsize)
403
+
404
+ _rows, cols = @terminal.winsize
405
+ [cols.to_i, 1].max
406
+ rescue StandardError
407
+ 80
408
+ end
409
+
410
+ def common_prefix(strings)
411
+ return "" if strings.empty?
412
+
413
+ prefix = strings.first.dup
414
+ strings[1..]&.each do |s|
415
+ while !prefix.empty? && !s.start_with?(prefix)
416
+ prefix = prefix[0...-1]
417
+ end
418
+ end
419
+ prefix
420
+ end
421
+
422
+ def insert_completion_noselect?
423
+ @editor.effective_option("completeopt").to_s.split(",").map { |s| s.strip.downcase }.include?("noselect")
424
+ end
425
+
426
+ def insert_completion_noinsert?
427
+ @editor.effective_option("completeopt").to_s.split(",").map { |s| s.strip.downcase }.include?("noinsert")
428
+ end
429
+
430
+ def insert_completion_menu_enabled?
431
+ opts = @editor.effective_option("completeopt").to_s.split(",").map { |s| s.strip.downcase }
432
+ opts.include?("menu") || opts.include?("menuone")
433
+ end
434
+
435
+ def show_insert_completion_menu(matches, selected:, current: nil)
436
+ if insert_completion_menu_enabled?
437
+ limit = [@editor.effective_option("pumheight").to_i, 1].max
438
+ items = matches.first(limit).each_with_index.map do |m, i|
439
+ i == selected ? "[#{m}]" : m
440
+ end
441
+ items << "..." if matches.length > limit
442
+ if current
443
+ @editor.echo("#{current} (#{selected + 1}/#{matches.length}) | #{items.join(' ')}")
444
+ else
445
+ @editor.echo(items.join(" "))
446
+ end
447
+ elsif current
448
+ @editor.echo("#{current} (#{selected + 1}/#{matches.length})")
449
+ end
450
+ end
451
+
452
+ def ensure_insert_completion_state
453
+ row = @editor.current_window.cursor_y
454
+ col = @editor.current_window.cursor_x
455
+ line = @editor.current_buffer.line_at(row)
456
+ prefix = trailing_keyword_fragment(line[0...col].to_s, @editor.current_window, @editor.current_buffer)
457
+ return nil if prefix.nil? || prefix.empty?
458
+
459
+ start_col = col - prefix.length
460
+ current_token = line[start_col...col].to_s
461
+ state = @insert_completion
462
+
463
+ if state &&
464
+ state[:row] == row &&
465
+ state[:start_col] == start_col &&
466
+ state[:prefix] == prefix &&
467
+ col == state[:current_end_col]
468
+ return state
469
+ end
470
+
471
+ matches = collect_buffer_word_completions(prefix, current_word: current_token)
472
+ @insert_completion = {
473
+ row: row,
474
+ start_col: start_col,
475
+ prefix: prefix,
476
+ matches: matches,
477
+ index: nil,
478
+ current_end_col: col
479
+ }
480
+ end
481
+
482
+ def collect_buffer_word_completions(prefix, current_word:)
483
+ words = []
484
+ seen = {}
485
+ rx = keyword_scan_regex(@editor.current_window, @editor.current_buffer)
486
+ @editor.buffers.values.each do |buf|
487
+ buf.lines.each do |line|
488
+ line.scan(rx) do |w|
489
+ next unless w.start_with?(prefix)
490
+ next if w == current_word
491
+ next if seen[w]
492
+
493
+ seen[w] = true
494
+ words << w
495
+ end
496
+ end
497
+ end
498
+ words.sort
499
+ end
500
+
501
+ def ensure_incsearch_preview_origin!(direction:)
502
+ return if @incsearch_preview
503
+
504
+ @incsearch_preview = {
505
+ origin: @editor.current_location,
506
+ direction: direction,
507
+ active: false
508
+ }
509
+ end
510
+
511
+ def keyword_scan_regex(window, buffer)
512
+ cls = keyword_char_class(window, buffer)
513
+ /[#{cls}]+/
514
+ rescue RegexpError
515
+ /[[:alnum:]_]+/
516
+ end
517
+
518
+ def keyword_char_class(window, buffer)
519
+ raw = @editor.effective_option("iskeyword", window:, buffer:).to_s
520
+ RuVim::KeywordChars.char_class(raw)
521
+ rescue StandardError
522
+ "[:alnum:]_"
523
+ end
524
+
525
+ def ex_completion_context(cmd)
526
+ text = cmd.text
527
+ cursor = cmd.cursor
528
+ token_start = token_start_index(text, cursor)
529
+ token_end = token_end_index(text, cursor)
530
+ prefix = text[token_start...cursor].to_s
531
+ before = text[0...token_start].to_s
532
+ argv_before = before.split(/\s+/).reject(&:empty?)
533
+
534
+ if argv_before.empty?
535
+ {
536
+ kind: :command,
537
+ token_start: token_start,
538
+ token_end: token_end,
539
+ prefix: prefix
540
+ }
541
+ else
542
+ {
543
+ kind: :arg,
544
+ command: argv_before.first,
545
+ arg_index: argv_before.length - 1,
546
+ token_start: token_start,
547
+ token_end: token_end,
548
+ prefix: prefix
549
+ }
550
+ end
551
+ end
552
+
553
+ def ex_completion_candidates(ctx)
554
+ case ctx[:kind]
555
+ when :command
556
+ ExCommandRegistry.instance.all.flat_map { |spec| [spec.name, *spec.aliases] }.uniq.sort.select { |n| n.start_with?(ctx[:prefix]) }
557
+ when :arg
558
+ ex_arg_completion_candidates(ctx[:command], ctx[:arg_index], ctx[:prefix])
559
+ else
560
+ []
561
+ end
562
+ end
563
+
564
+ def ex_arg_completion_candidates(command_name, arg_index, prefix)
565
+ return [] unless arg_index.zero?
566
+
567
+ if %w[e edit w write tabnew].include?(command_name)
568
+ return path_completion_candidates(prefix)
569
+ end
570
+
571
+ if %w[buffer b].include?(command_name)
572
+ return buffer_completion_candidates(prefix)
573
+ end
574
+
575
+ if %w[set setlocal setglobal].include?(command_name)
576
+ return option_completion_candidates(prefix)
577
+ end
578
+
579
+ if command_name == "git"
580
+ return git_subcommand_candidates(prefix)
581
+ end
582
+
583
+ if command_name == "gh"
584
+ return gh_subcommand_candidates(prefix)
585
+ end
586
+
587
+ []
588
+ end
589
+
590
+ def git_subcommand_candidates(prefix)
591
+ @git_subcommands ||= begin
592
+ builtin = Git::Handler::GIT_SUBCOMMANDS.keys
593
+ external = parse_git_help_subcommands
594
+ (builtin + external).uniq.sort
595
+ end
596
+ @git_subcommands.select { |s| s.start_with?(prefix) }
597
+ end
598
+
599
+ def gh_subcommand_candidates(prefix)
600
+ @gh_subcommands ||= begin
601
+ builtin = Git::Handler::GH_SUBCOMMANDS.keys
602
+ external = parse_gh_help_subcommands
603
+ (builtin + external).uniq.sort
604
+ end
605
+ @gh_subcommands.select { |s| s.start_with?(prefix) }
606
+ end
607
+
608
+ def parse_git_help_subcommands
609
+ out, _, status = Open3.capture3("git", "help", "-a")
610
+ return [] unless status.success?
611
+
612
+ out.each_line.filter_map { |line|
613
+ line.match(/\A (\S+)\s/)&.captures&.first
614
+ }
615
+ rescue StandardError
616
+ []
617
+ end
618
+
619
+ def parse_gh_help_subcommands
620
+ out, _, status = Open3.capture3("gh", "help")
621
+ return [] unless status.success?
622
+
623
+ out.each_line.filter_map { |line|
624
+ line.match(/\A (\w+):/)&.captures&.first
625
+ }
626
+ rescue StandardError
627
+ []
628
+ end
629
+
630
+ def path_completion_candidates(prefix)
631
+ base_dir =
632
+ if prefix.empty?
633
+ "."
634
+ elsif prefix.end_with?("/")
635
+ prefix
636
+ else
637
+ File.dirname(prefix)
638
+ end
639
+ partial = prefix.end_with?("/") ? "" : File.basename(prefix)
640
+ pattern =
641
+ if prefix.empty?
642
+ "*"
643
+ elsif base_dir == "."
644
+ "#{partial}*"
645
+ else
646
+ File.join(base_dir, "#{partial}*")
647
+ end
648
+ partial_starts_with_dot = partial.start_with?(".")
649
+ entries = Dir.glob(pattern, File::FNM_DOTMATCH).filter_map do |p|
650
+ next if [".", ".."].include?(File.basename(p))
651
+ next unless p.start_with?(prefix) || prefix.empty?
652
+ next if wildignore_path?(p)
653
+ File.directory?(p) ? "#{p}/" : p
654
+ end
655
+ entries.sort_by do |p|
656
+ base = File.basename(p.sub(%r{/\z}, ""))
657
+ hidden_rank = (!partial_starts_with_dot && base.start_with?(".")) ? 1 : 0
658
+ [hidden_rank, p]
659
+ end
660
+ rescue StandardError
661
+ []
662
+ end
663
+
664
+ def wildignore_path?(path)
665
+ spec = @editor.global_options["wildignore"].to_s
666
+ return false if spec.empty?
667
+
668
+ flags = @editor.global_options["wildignorecase"] ? File::FNM_CASEFOLD : 0
669
+ base = File.basename(path)
670
+ spec.split(",").map(&:strip).reject(&:empty?).any? do |pat|
671
+ File.fnmatch?(pat, path, flags) || File.fnmatch?(pat, base, flags)
672
+ end
673
+ rescue StandardError
674
+ false
675
+ end
676
+
677
+ def buffer_completion_candidates(prefix)
678
+ items = @editor.buffers.values.flat_map do |b|
679
+ path = b.path.to_s
680
+ base = path.empty? ? nil : File.basename(path)
681
+ [b.id.to_s, path, base].compact
682
+ end.uniq.sort
683
+ items.select { |s| s.start_with?(prefix) }
684
+ end
685
+
686
+ def option_completion_candidates(prefix)
687
+ names = RuVim::Editor::OPTION_DEFS.keys
688
+ tokens = names + names.map { |n| "no#{n}" } + names.map { |n| "inv#{n}" } + names.map { |n| "#{n}?" }
689
+ tokens.uniq.sort.select { |s| s.start_with?(prefix) }
690
+ end
691
+
692
+ def token_start_index(text, cursor)
693
+ i = [[cursor, 0].max, text.length].min
694
+ i -= 1 while i.positive? && !whitespace_char?(text[i - 1])
695
+ i
696
+ end
697
+
698
+ def token_end_index(text, cursor)
699
+ i = [[cursor, 0].max, text.length].min
700
+ i += 1 while i < text.length && !whitespace_char?(text[i])
701
+ i
702
+ end
703
+
704
+ def whitespace_char?(ch)
705
+ ch && ch.match?(/\s/)
706
+ end
707
+ end
708
+ end