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.
- checksums.yaml +4 -4
- data/AGENTS.md +53 -4
- data/README.md +15 -6
- data/Rakefile +7 -0
- data/benchmark/cext_compare.rb +165 -0
- data/benchmark/chunked_load.rb +256 -0
- data/benchmark/file_load.rb +140 -0
- data/benchmark/hotspots.rb +178 -0
- data/docs/binding.md +3 -2
- data/docs/command.md +81 -9
- data/docs/done.md +23 -0
- data/docs/spec.md +105 -19
- data/docs/todo.md +9 -0
- data/docs/tutorial.md +9 -1
- data/docs/vim_diff.md +13 -0
- data/ext/ruvim/extconf.rb +5 -0
- data/ext/ruvim/ruvim_ext.c +519 -0
- data/lib/ruvim/app.rb +217 -2778
- data/lib/ruvim/browser.rb +104 -0
- data/lib/ruvim/buffer.rb +39 -28
- data/lib/ruvim/command_invocation.rb +2 -2
- data/lib/ruvim/completion_manager.rb +708 -0
- data/lib/ruvim/dispatcher.rb +14 -8
- data/lib/ruvim/display_width.rb +91 -45
- data/lib/ruvim/editor.rb +64 -81
- data/lib/ruvim/ex_command_registry.rb +3 -1
- data/lib/ruvim/gh/link.rb +207 -0
- data/lib/ruvim/git/blame.rb +16 -6
- data/lib/ruvim/git/branch.rb +20 -5
- data/lib/ruvim/git/grep.rb +107 -0
- data/lib/ruvim/git/handler.rb +42 -1
- data/lib/ruvim/global_commands.rb +175 -35
- data/lib/ruvim/highlighter.rb +4 -13
- data/lib/ruvim/key_handler.rb +1510 -0
- data/lib/ruvim/keymap_manager.rb +7 -7
- data/lib/ruvim/lang/base.rb +5 -0
- data/lib/ruvim/lang/c.rb +116 -0
- data/lib/ruvim/lang/cpp.rb +107 -0
- data/lib/ruvim/lang/csv.rb +4 -1
- data/lib/ruvim/lang/diff.rb +2 -0
- data/lib/ruvim/lang/dockerfile.rb +36 -0
- data/lib/ruvim/lang/elixir.rb +85 -0
- data/lib/ruvim/lang/erb.rb +30 -0
- data/lib/ruvim/lang/go.rb +83 -0
- data/lib/ruvim/lang/html.rb +34 -0
- data/lib/ruvim/lang/javascript.rb +83 -0
- data/lib/ruvim/lang/json.rb +6 -0
- data/lib/ruvim/lang/lua.rb +76 -0
- data/lib/ruvim/lang/makefile.rb +36 -0
- data/lib/ruvim/lang/markdown.rb +3 -4
- data/lib/ruvim/lang/ocaml.rb +77 -0
- data/lib/ruvim/lang/perl.rb +91 -0
- data/lib/ruvim/lang/python.rb +85 -0
- data/lib/ruvim/lang/registry.rb +102 -0
- data/lib/ruvim/lang/ruby.rb +7 -0
- data/lib/ruvim/lang/rust.rb +95 -0
- data/lib/ruvim/lang/scheme.rb +5 -0
- data/lib/ruvim/lang/sh.rb +76 -0
- data/lib/ruvim/lang/sql.rb +52 -0
- data/lib/ruvim/lang/toml.rb +36 -0
- data/lib/ruvim/lang/tsv.rb +4 -1
- data/lib/ruvim/lang/typescript.rb +53 -0
- data/lib/ruvim/lang/yaml.rb +62 -0
- data/lib/ruvim/rich_view/table_renderer.rb +3 -3
- data/lib/ruvim/rich_view.rb +14 -7
- data/lib/ruvim/screen.rb +126 -72
- data/lib/ruvim/stream/file_load.rb +85 -0
- data/lib/ruvim/stream/follow.rb +40 -0
- data/lib/ruvim/stream/git.rb +43 -0
- data/lib/ruvim/stream/run.rb +74 -0
- data/lib/ruvim/stream/stdin.rb +55 -0
- data/lib/ruvim/stream.rb +35 -0
- data/lib/ruvim/stream_mixer.rb +394 -0
- data/lib/ruvim/terminal.rb +18 -4
- data/lib/ruvim/text_metrics.rb +84 -65
- data/lib/ruvim/version.rb +1 -1
- data/lib/ruvim/window.rb +5 -5
- data/lib/ruvim.rb +23 -6
- data/test/app_command_test.rb +382 -0
- data/test/app_completion_test.rb +43 -19
- data/test/app_dot_repeat_test.rb +27 -3
- data/test/app_ex_command_test.rb +154 -0
- data/test/app_motion_test.rb +13 -12
- data/test/app_register_test.rb +2 -1
- data/test/app_scenario_test.rb +15 -10
- data/test/app_startup_test.rb +70 -27
- data/test/app_text_object_test.rb +2 -1
- data/test/app_unicode_behavior_test.rb +3 -2
- data/test/browser_test.rb +88 -0
- data/test/buffer_test.rb +24 -0
- data/test/cli_test.rb +63 -0
- data/test/command_invocation_test.rb +33 -0
- data/test/config_dsl_test.rb +47 -0
- data/test/dispatcher_test.rb +74 -4
- data/test/ex_command_registry_test.rb +106 -0
- data/test/follow_test.rb +20 -21
- data/test/gh_link_test.rb +141 -0
- data/test/git_blame_test.rb +96 -17
- data/test/git_grep_test.rb +64 -0
- data/test/highlighter_test.rb +125 -0
- data/test/indent_test.rb +137 -0
- data/test/input_screen_integration_test.rb +1 -1
- data/test/keyword_chars_test.rb +85 -0
- data/test/lang_test.rb +634 -0
- data/test/markdown_renderer_test.rb +5 -5
- data/test/on_save_hook_test.rb +12 -8
- data/test/render_snapshot_test.rb +78 -0
- data/test/rich_view_test.rb +42 -42
- data/test/run_command_test.rb +307 -0
- data/test/screen_test.rb +68 -5
- data/test/stream_test.rb +165 -0
- data/test/window_test.rb +59 -0
- 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
|