rubish-gem 0.0.1
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 +7 -0
- data/.dockerignore +23 -0
- data/Dockerfile +54 -0
- data/LICENSE.txt +21 -0
- data/README.md +39 -0
- data/Rakefile +12 -0
- data/lib/rubish/arithmetic.rb +140 -0
- data/lib/rubish/ast.rb +168 -0
- data/lib/rubish/builtins/arithmetic.rb +129 -0
- data/lib/rubish/builtins/bind_readline.rb +834 -0
- data/lib/rubish/builtins/directory_stack.rb +182 -0
- data/lib/rubish/builtins/echo_printf.rb +510 -0
- data/lib/rubish/builtins/hash_directories.rb +260 -0
- data/lib/rubish/builtins/read.rb +299 -0
- data/lib/rubish/builtins/trap.rb +324 -0
- data/lib/rubish/codegen.rb +1273 -0
- data/lib/rubish/completion.rb +840 -0
- data/lib/rubish/completions/bash_helpers.rb +530 -0
- data/lib/rubish/completions/git.rb +431 -0
- data/lib/rubish/completions/help_parser.rb +453 -0
- data/lib/rubish/completions/ssh.rb +114 -0
- data/lib/rubish/config.rb +267 -0
- data/lib/rubish/data/builtin_help.rb +716 -0
- data/lib/rubish/data/completion_data.rb +53 -0
- data/lib/rubish/data/readline_config.rb +47 -0
- data/lib/rubish/data/shell_options.rb +251 -0
- data/lib/rubish/data_define.rb +65 -0
- data/lib/rubish/execution_context.rb +1124 -0
- data/lib/rubish/expansion.rb +988 -0
- data/lib/rubish/history.rb +663 -0
- data/lib/rubish/lazy_loader.rb +127 -0
- data/lib/rubish/lexer.rb +1194 -0
- data/lib/rubish/parser.rb +1167 -0
- data/lib/rubish/prompt.rb +766 -0
- data/lib/rubish/repl.rb +2267 -0
- data/lib/rubish/runtime/builtins.rb +7222 -0
- data/lib/rubish/runtime/command.rb +1153 -0
- data/lib/rubish/runtime/job.rb +153 -0
- data/lib/rubish/runtime.rb +1169 -0
- data/lib/rubish/shell_state.rb +241 -0
- data/lib/rubish/startup_profiler.rb +67 -0
- data/lib/rubish/version.rb +5 -0
- data/lib/rubish.rb +60 -0
- data/sig/rubish.rbs +4 -0
- metadata +85 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubish
|
|
4
|
+
module Builtins
|
|
5
|
+
# ==========================================================================
|
|
6
|
+
# Auto-completion by parsing --help output (fish-style)
|
|
7
|
+
# ==========================================================================
|
|
8
|
+
|
|
9
|
+
# Cache for parsed help output: { command => { subcommands: [...], options: [...], timestamp: Time } }
|
|
10
|
+
@help_completion_cache = {}
|
|
11
|
+
HELP_CACHE_TTL = 1800 # 30 minutes
|
|
12
|
+
|
|
13
|
+
# Get zsh's fpath for completion file directories
|
|
14
|
+
@zsh_fpath = nil
|
|
15
|
+
def zsh_fpath
|
|
16
|
+
return @zsh_fpath if @zsh_fpath
|
|
17
|
+
|
|
18
|
+
@zsh_fpath = `zsh -c 'print -l $fpath' 2>/dev/null`.split("\n").select { |d| Dir.exist?(d) }
|
|
19
|
+
rescue
|
|
20
|
+
@zsh_fpath = []
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Timeout for help command execution (seconds)
|
|
24
|
+
HELP_COMMAND_TIMEOUT = 2
|
|
25
|
+
|
|
26
|
+
# macOS sandbox profile for running help commands safely
|
|
27
|
+
# Denies network access, allows reads and writes only to safe locations
|
|
28
|
+
SANDBOX_PROFILE = <<~PROFILE
|
|
29
|
+
(version 1)
|
|
30
|
+
(deny default)
|
|
31
|
+
(allow process-fork process-exec)
|
|
32
|
+
(allow file-read*)
|
|
33
|
+
(allow file-read-metadata)
|
|
34
|
+
(allow sysctl-read)
|
|
35
|
+
(allow mach-lookup)
|
|
36
|
+
(allow signal (target self))
|
|
37
|
+
(deny network*)
|
|
38
|
+
; Allow writes to /dev/null and temp directories (needed by man, etc.)
|
|
39
|
+
(allow file-write* (subpath "/dev"))
|
|
40
|
+
(allow file-write* (subpath "/tmp"))
|
|
41
|
+
(allow file-write* (subpath "/private/tmp"))
|
|
42
|
+
(allow file-write* (subpath "/var/folders"))
|
|
43
|
+
(allow file-write* (subpath "/private/var/folders"))
|
|
44
|
+
; Deny writes everywhere else
|
|
45
|
+
(deny file-write* (subpath "/Users"))
|
|
46
|
+
(deny file-write* (subpath "/System"))
|
|
47
|
+
(deny file-write* (subpath "/Applications"))
|
|
48
|
+
PROFILE
|
|
49
|
+
|
|
50
|
+
# Run a help command in a sandboxed environment with timeout
|
|
51
|
+
# Returns [output, success] or [nil, false] on failure/timeout
|
|
52
|
+
def sandboxed_help_command(help_cmd)
|
|
53
|
+
Kernel.require 'open3'
|
|
54
|
+
Kernel.require 'tempfile'
|
|
55
|
+
|
|
56
|
+
pid = nil
|
|
57
|
+
output = nil
|
|
58
|
+
success = false
|
|
59
|
+
|
|
60
|
+
begin
|
|
61
|
+
if RUBY_PLATFORM.include?('darwin')
|
|
62
|
+
# macOS: use sandbox-exec for additional isolation
|
|
63
|
+
profile_file = Tempfile.new(['sandbox', '.sb'])
|
|
64
|
+
begin
|
|
65
|
+
profile_file.write(SANDBOX_PROFILE)
|
|
66
|
+
profile_file.close
|
|
67
|
+
|
|
68
|
+
stdin, stdout_err, wait_thr = Open3.popen2e('sandbox-exec', '-f', profile_file.path, 'sh', '-c', help_cmd)
|
|
69
|
+
pid = wait_thr.pid
|
|
70
|
+
stdin.close
|
|
71
|
+
|
|
72
|
+
# Use select with timeout to read output
|
|
73
|
+
ready = IO.select([stdout_err], nil, nil, HELP_COMMAND_TIMEOUT)
|
|
74
|
+
if ready
|
|
75
|
+
output = stdout_err.read
|
|
76
|
+
wait_thr.join(HELP_COMMAND_TIMEOUT)
|
|
77
|
+
success = wait_thr.value&.success? || false
|
|
78
|
+
else
|
|
79
|
+
# Timeout - kill the process
|
|
80
|
+
Process.kill('TERM', pid) rescue nil
|
|
81
|
+
Process.kill('KILL', pid) rescue nil
|
|
82
|
+
success = false
|
|
83
|
+
end
|
|
84
|
+
stdout_err.close
|
|
85
|
+
ensure
|
|
86
|
+
profile_file.unlink
|
|
87
|
+
end
|
|
88
|
+
else
|
|
89
|
+
# Other platforms: run with timeout protection
|
|
90
|
+
stdin, stdout_err, wait_thr = Open3.popen2e(help_cmd)
|
|
91
|
+
pid = wait_thr.pid
|
|
92
|
+
stdin.close
|
|
93
|
+
|
|
94
|
+
ready = IO.select([stdout_err], nil, nil, HELP_COMMAND_TIMEOUT)
|
|
95
|
+
if ready
|
|
96
|
+
output = stdout_err.read
|
|
97
|
+
wait_thr.join(HELP_COMMAND_TIMEOUT)
|
|
98
|
+
success = wait_thr.value&.success? || false
|
|
99
|
+
else
|
|
100
|
+
Process.kill('TERM', pid) rescue nil
|
|
101
|
+
Process.kill('KILL', pid) rescue nil
|
|
102
|
+
success = false
|
|
103
|
+
end
|
|
104
|
+
stdout_err.close
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
[output, success]
|
|
108
|
+
rescue Errno::ENOENT
|
|
109
|
+
# Command not found
|
|
110
|
+
[nil, false]
|
|
111
|
+
rescue => e
|
|
112
|
+
# Kill process if still running
|
|
113
|
+
if pid
|
|
114
|
+
Process.kill('TERM', pid) rescue nil
|
|
115
|
+
Process.kill('KILL', pid) rescue nil
|
|
116
|
+
end
|
|
117
|
+
[nil, false]
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Known help sources for popular commands (command => help invocation)
|
|
122
|
+
# Note: git, ssh, make, man, kill have dedicated completion functions
|
|
123
|
+
HELP_COMMAND_SOURCES = {
|
|
124
|
+
'aws' => 'aws help',
|
|
125
|
+
'bundle' => 'bundle --help',
|
|
126
|
+
'gem' => 'gem help commands',
|
|
127
|
+
'rails' => 'rails --help',
|
|
128
|
+
'brew' => 'brew commands',
|
|
129
|
+
'npm' => 'npm help',
|
|
130
|
+
'yarn' => 'yarn --help',
|
|
131
|
+
'cargo' => 'cargo --list',
|
|
132
|
+
'docker' => 'docker --help',
|
|
133
|
+
'go' => 'go help',
|
|
134
|
+
'pip' => 'pip --help',
|
|
135
|
+
'rustup' => 'rustup --help'
|
|
136
|
+
}.freeze
|
|
137
|
+
|
|
138
|
+
def _auto_completion(cmd, cur, prev)
|
|
139
|
+
words = @comp_words
|
|
140
|
+
cword = @comp_cword
|
|
141
|
+
command = words[0]
|
|
142
|
+
|
|
143
|
+
# Parse help output for this command
|
|
144
|
+
parsed = parse_help_for_command(command)
|
|
145
|
+
return if parsed.nil?
|
|
146
|
+
|
|
147
|
+
# Find if we're completing a subcommand's arguments
|
|
148
|
+
subcommand = nil
|
|
149
|
+
words.each_with_index do |word, idx|
|
|
150
|
+
next if idx == 0
|
|
151
|
+
next if word.start_with?('-')
|
|
152
|
+
if parsed[:subcommands].include?(word)
|
|
153
|
+
subcommand = word
|
|
154
|
+
break
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
if subcommand && cword > 1
|
|
159
|
+
# Try to get help for the subcommand
|
|
160
|
+
sub_parsed = parse_help_for_command(command, subcommand)
|
|
161
|
+
if sub_parsed
|
|
162
|
+
if cur.start_with?('-')
|
|
163
|
+
@compreply = sub_parsed[:options].select { |o| o.start_with?(cur) }
|
|
164
|
+
else
|
|
165
|
+
@compreply = sub_parsed[:subcommands].select { |s| s.start_with?(cur) }
|
|
166
|
+
end
|
|
167
|
+
return
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Complete top-level
|
|
172
|
+
if cur.start_with?('-')
|
|
173
|
+
@compreply = parsed[:options].select { |o| o.start_with?(cur) }
|
|
174
|
+
else
|
|
175
|
+
@compreply = parsed[:subcommands].select { |s| s.start_with?(cur) }
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def parse_help_for_command(command, subcommand = nil)
|
|
180
|
+
# Skip commands that look like shell operators or Ruby syntax
|
|
181
|
+
return nil if command =~ /\A[-+:=<>|&!]\z/
|
|
182
|
+
|
|
183
|
+
cache_key = subcommand ? "#{command} #{subcommand}" : command
|
|
184
|
+
|
|
185
|
+
# Check cache
|
|
186
|
+
cached = @help_completion_cache[cache_key]
|
|
187
|
+
if cached && (Time.now - cached[:timestamp]) < HELP_CACHE_TTL
|
|
188
|
+
return cached
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
parsed = nil
|
|
192
|
+
|
|
193
|
+
# Try zsh completion file first (for top-level commands only)
|
|
194
|
+
if subcommand.nil?
|
|
195
|
+
parsed = parse_zsh_completion_file(command)
|
|
196
|
+
if parsed && parsed[:subcommands].length >= 3
|
|
197
|
+
parsed[:timestamp] = Time.now
|
|
198
|
+
@help_completion_cache[cache_key] = parsed
|
|
199
|
+
return parsed
|
|
200
|
+
end
|
|
201
|
+
# Reset parsed if zsh result has too few subcommands (likely false positives)
|
|
202
|
+
parsed = nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Fall back to help output parsing
|
|
206
|
+
# Note: We only try "command help" for known commands that support it,
|
|
207
|
+
# because for unknown commands like "touch", "touch help" would create a file!
|
|
208
|
+
help_commands = if subcommand
|
|
209
|
+
["#{command} #{subcommand} --help", "#{command} help #{subcommand}"]
|
|
210
|
+
elsif HELP_COMMAND_SOURCES.key?(command)
|
|
211
|
+
# Use known source for popular commands
|
|
212
|
+
[HELP_COMMAND_SOURCES[command]]
|
|
213
|
+
else
|
|
214
|
+
# For unknown commands, only try --help and -h (not bare "help" subcommand)
|
|
215
|
+
# to avoid side effects like "touch help" creating a file named "help"
|
|
216
|
+
["#{command} --help", "#{command} -h"]
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
help_output = nil
|
|
220
|
+
help_commands.each do |help_cmd|
|
|
221
|
+
# Run help command in sandbox with timeout for safety
|
|
222
|
+
output, success = sandboxed_help_command(help_cmd)
|
|
223
|
+
next unless success && output && output.length > 50
|
|
224
|
+
|
|
225
|
+
help_output = output
|
|
226
|
+
# Check if this output has good subcommand info
|
|
227
|
+
help_parsed = parse_help_output(output)
|
|
228
|
+
if help_parsed[:subcommands].length >= 3
|
|
229
|
+
parsed = help_parsed
|
|
230
|
+
break
|
|
231
|
+
elsif parsed.nil? || help_parsed[:subcommands].length > (parsed[:subcommands]&.length || 0)
|
|
232
|
+
parsed = help_parsed
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
return nil unless parsed
|
|
237
|
+
|
|
238
|
+
parsed[:timestamp] = Time.now
|
|
239
|
+
@help_completion_cache[cache_key] = parsed
|
|
240
|
+
parsed
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def parse_help_output(text)
|
|
244
|
+
subcommands = []
|
|
245
|
+
options = []
|
|
246
|
+
|
|
247
|
+
# Remove man page formatting:
|
|
248
|
+
# - Bold: A\bA (doubled characters like "bbuunnddllee")
|
|
249
|
+
# - Overstrike: +\bo (bullet points, underscore emphasis)
|
|
250
|
+
text = text.gsub(/(.)\x08\1/, '\1') # Bold: keep second char
|
|
251
|
+
text = text.gsub(/.\x08/, '') # Overstrike: keep second char (removes first)
|
|
252
|
+
# Remove ANSI escape codes
|
|
253
|
+
text = text.gsub(/\e\[[0-9;]*m/, '')
|
|
254
|
+
|
|
255
|
+
lines = text.lines.map(&:chomp)
|
|
256
|
+
in_commands_section = false
|
|
257
|
+
in_options_section = false
|
|
258
|
+
|
|
259
|
+
lines.each do |line|
|
|
260
|
+
# Detect section headers
|
|
261
|
+
if line =~ /^(Commands|COMMANDS|Subcommands|SUBCOMMANDS|Available commands):/i ||
|
|
262
|
+
line =~ /commands are:$/i ||
|
|
263
|
+
line =~ /^=+>\s*(Built-in\s+)?commands$/i ||
|
|
264
|
+
line =~ /^(PRIMARY|UTILITIES|BUNDLE)\s+COMMANDS$/i ||
|
|
265
|
+
line =~ /^AVAILABLE SERVICES$/ # AWS CLI style
|
|
266
|
+
in_commands_section = true
|
|
267
|
+
in_options_section = false
|
|
268
|
+
next
|
|
269
|
+
elsif line =~ /^(Options|OPTIONS|Flags|FLAGS|Global options):/i ||
|
|
270
|
+
line =~ /^GLOBAL OPTIONS$/ # AWS CLI style
|
|
271
|
+
in_commands_section = false
|
|
272
|
+
in_options_section = true
|
|
273
|
+
next
|
|
274
|
+
elsif line =~ /^[A-Z][-A-Za-z_]+:$/ || line =~ /^[A-Z][-A-Za-z_]+\s+[-A-Za-z_]+:$/
|
|
275
|
+
# Short section header (1-2 words) that's not a commands section
|
|
276
|
+
# Set in_options_section to true to suppress subcommand detection
|
|
277
|
+
# (sections like "Features:", "Warning categories:", "Dump List:", "YJIT options:")
|
|
278
|
+
in_commands_section = false
|
|
279
|
+
in_options_section = true
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Parse subcommands in different formats:
|
|
283
|
+
# 1. Simple list: one command per line (brew commands)
|
|
284
|
+
# 2. Table format: " command description" (gem help commands)
|
|
285
|
+
# 3. Man page format: "bundle install(1)"
|
|
286
|
+
if in_commands_section
|
|
287
|
+
# Simple single-word per line (brew commands style)
|
|
288
|
+
if line =~ /^([a-z][-a-z0-9_]*)$/
|
|
289
|
+
subcommands << $1
|
|
290
|
+
# Table format with description
|
|
291
|
+
elsif line =~ /^\s{2,}([a-z][-a-z0-9_:]*)\s{2,}/
|
|
292
|
+
cmd = $1
|
|
293
|
+
subcommands << cmd if cmd.length < 30 && !cmd.include?('=')
|
|
294
|
+
# Man page format: "bundle install(1)"
|
|
295
|
+
elsif line =~ /^\s+\w+\s+([a-z][-a-z0-9_]*)\s*\(\d\)/
|
|
296
|
+
subcommands << $1
|
|
297
|
+
# Bullet-point format: " o service" (AWS CLI style, from man page)
|
|
298
|
+
elsif line =~ /^\s+o\s+([a-z][-a-z0-9_]+)$/
|
|
299
|
+
subcommands << $1
|
|
300
|
+
end
|
|
301
|
+
elsif !in_options_section
|
|
302
|
+
# Outside of explicit sections, try to detect command patterns
|
|
303
|
+
# Table format with description (e.g., git's " clone Clone a repository")
|
|
304
|
+
if line =~ /^\s{2,4}([a-z][-a-z0-9_]*)\s{2,}\S/
|
|
305
|
+
cmd = $1
|
|
306
|
+
# Skip common English words that appear in help text (e.g., "or java [options]...")
|
|
307
|
+
next if %w[or and the for to in of on at by as is it if an are be do no so].include?(cmd)
|
|
308
|
+
subcommands << cmd if cmd.length < 25 && !cmd.include?('=')
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Parse options - look for -x or --xxx patterns
|
|
313
|
+
if line =~ /(^|\s)(--?[a-zA-Z][-a-zA-Z0-9_]*)/
|
|
314
|
+
line.scan(/(?:^|\s)(--?[a-zA-Z][-a-zA-Z0-9_]*)(?:[,=\s\[]|$)/).flatten.each do |opt|
|
|
315
|
+
options << opt unless opt =~ /^-\d/ # Skip things like -1, -2
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
{subcommands: subcommands.uniq, options: options.uniq}
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# ==========================================================================
|
|
324
|
+
# Zsh completion file parsing
|
|
325
|
+
# ==========================================================================
|
|
326
|
+
|
|
327
|
+
# Find zsh completion file for a command
|
|
328
|
+
def find_zsh_completion_file(command)
|
|
329
|
+
zsh_fpath.each do |dir|
|
|
330
|
+
path = File.join(dir, "_#{command}")
|
|
331
|
+
return path if File.exist?(path)
|
|
332
|
+
end
|
|
333
|
+
nil
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Parse zsh completion file to extract subcommands and options
|
|
337
|
+
# First tries to find and execute the actual commands zsh uses,
|
|
338
|
+
# then falls back to static parsing
|
|
339
|
+
def parse_zsh_completion_file(command)
|
|
340
|
+
path = find_zsh_completion_file(command)
|
|
341
|
+
return nil unless path
|
|
342
|
+
|
|
343
|
+
content = File.read(path)
|
|
344
|
+
subcommands = []
|
|
345
|
+
options = []
|
|
346
|
+
|
|
347
|
+
# Strategy 1: Find and execute the commands that zsh completions use
|
|
348
|
+
# Look for patterns like: $(_call_program commands cargo --list)
|
|
349
|
+
# or: $(cargo --list) or `cargo --list`
|
|
350
|
+
extracted_cmds = extract_zsh_completion_commands(content, command)
|
|
351
|
+
extracted_cmds.each do |cmd|
|
|
352
|
+
output = with_timeout(cmd, 2)
|
|
353
|
+
next unless output && output.length > 10
|
|
354
|
+
|
|
355
|
+
# Parse the output for subcommands
|
|
356
|
+
output.each_line do |line|
|
|
357
|
+
line = line.strip
|
|
358
|
+
# Common formats:
|
|
359
|
+
# " subcommand description" (cargo --list)
|
|
360
|
+
# "subcommand" (simple list)
|
|
361
|
+
# "subcommand:description" (already parsed)
|
|
362
|
+
if line =~ /^\s{2,}(\S+)/ || line =~ /^([a-z][-a-z0-9_]+)(?:\s|$|:)/
|
|
363
|
+
sub = $1
|
|
364
|
+
subcommands << sub if sub.length < 30 && sub =~ /^[a-z]/
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Strategy 2: Parse inline subcommand definitions
|
|
370
|
+
# e.g., 'add:Add a dependency'
|
|
371
|
+
content.scan(/'([a-z][-a-z0-9_]*):[^']*'/).each do |match|
|
|
372
|
+
subcommands << match[0]
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Strategy 3: Parse array definitions
|
|
376
|
+
# commands=( 'add:desc' 'build:desc' ... ) or hardcoded arrays
|
|
377
|
+
content.scan(/(?:commands?|cmds|subcmds)\s*=\s*\(\s*([^)]+)\)/m).each do |match|
|
|
378
|
+
# Match 'subcommand:description' or 'subcommand' patterns
|
|
379
|
+
match[0].scan(/'([a-z][-a-z0-9_]+)(?::|')/).each do |cmd|
|
|
380
|
+
subcommands << cmd[0] if cmd[0].length < 25
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Strategy 4: Options from _arguments specs
|
|
385
|
+
content.scan(/'(-[a-zA-Z])['\[\s]/).each do |match|
|
|
386
|
+
options << match[0]
|
|
387
|
+
end
|
|
388
|
+
content.scan(/'(--[a-zA-Z][-a-zA-Z0-9_]*)['\[\s=]/).each do |match|
|
|
389
|
+
options << match[0]
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
return nil if subcommands.empty? && options.empty?
|
|
393
|
+
|
|
394
|
+
{
|
|
395
|
+
subcommands: subcommands.uniq.sort,
|
|
396
|
+
options: options.uniq.sort,
|
|
397
|
+
source: :zsh
|
|
398
|
+
}
|
|
399
|
+
rescue
|
|
400
|
+
nil
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Extract shell commands from zsh completion file that fetch subcommands
|
|
404
|
+
def extract_zsh_completion_commands(content, command)
|
|
405
|
+
cmds = []
|
|
406
|
+
|
|
407
|
+
# Pattern: _call_program <tag> <command>
|
|
408
|
+
# e.g., _call_program commands cargo --list
|
|
409
|
+
content.scan(/_call_program\s+(\w+)\s+([^)"'\n]+)/).each do |match|
|
|
410
|
+
tag, cmd = match[0], match[1].strip
|
|
411
|
+
# Only include commands that look like subcommand listing
|
|
412
|
+
next unless cmd.start_with?(command)
|
|
413
|
+
# Tag must indicate commands/subcommands
|
|
414
|
+
next unless tag =~ /^commands?$/i
|
|
415
|
+
cmds << cmd
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# Pattern: $(<command>) command substitution for listing
|
|
419
|
+
# e.g., $(cargo --list) - must be simple "command --list" style
|
|
420
|
+
content.scan(/\$\(([^)]+)\)/).each do |match|
|
|
421
|
+
cmd = match[0].strip
|
|
422
|
+
next unless cmd.start_with?(command)
|
|
423
|
+
next if cmd.include?('_call_program')
|
|
424
|
+
# Only simple list commands: "cmd --list" or "cmd help"
|
|
425
|
+
next unless cmd =~ /^#{Regexp.escape(command)}\s+(--list|help|commands)$/
|
|
426
|
+
cmds << cmd
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# Pattern: `<command>` backtick substitution
|
|
430
|
+
content.scan(/`([^`]+)`/).each do |match|
|
|
431
|
+
cmd = match[0].strip
|
|
432
|
+
next unless cmd.start_with?(command)
|
|
433
|
+
next unless cmd =~ /^#{Regexp.escape(command)}\s+(--list|help|commands)$/
|
|
434
|
+
cmds << cmd
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
cmds.uniq
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# Execute a command with timeout, returns output or nil
|
|
441
|
+
def with_timeout(cmd, timeout = 2)
|
|
442
|
+
output = nil
|
|
443
|
+
begin
|
|
444
|
+
Timeout.timeout(timeout) do
|
|
445
|
+
output = `#{cmd} 2>/dev/null`
|
|
446
|
+
end
|
|
447
|
+
rescue Timeout::Error
|
|
448
|
+
output = nil
|
|
449
|
+
end
|
|
450
|
+
output
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubish
|
|
4
|
+
module Builtins
|
|
5
|
+
# ==========================================================================
|
|
6
|
+
# SSH completion function
|
|
7
|
+
# ==========================================================================
|
|
8
|
+
def _ssh_completion(cmd, cur, prev)
|
|
9
|
+
case prev
|
|
10
|
+
when '-F', '-i', '-S', '-E', '-c', '-o'
|
|
11
|
+
# File/config completions
|
|
12
|
+
if %w[-F -i -S -E].include?(prev)
|
|
13
|
+
_filedir([])
|
|
14
|
+
else
|
|
15
|
+
@compreply = []
|
|
16
|
+
end
|
|
17
|
+
return
|
|
18
|
+
when '-l'
|
|
19
|
+
# Username completion
|
|
20
|
+
_usergroup(['-u'])
|
|
21
|
+
return
|
|
22
|
+
when '-p'
|
|
23
|
+
# Port number
|
|
24
|
+
@compreply = []
|
|
25
|
+
return
|
|
26
|
+
when '-J'
|
|
27
|
+
# Jump host - same as hostname
|
|
28
|
+
_ssh_complete_hosts(cur)
|
|
29
|
+
return
|
|
30
|
+
when '-O'
|
|
31
|
+
@compreply = %w[check forward cancel exit stop].select { |opt| opt.start_with?(cur) }
|
|
32
|
+
return
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
if cur.start_with?('-')
|
|
36
|
+
opts = %w[-4 -6 -A -a -C -f -G -g -K -k -M -N -n -q -s -T -t -V -v -X -x -Y -y
|
|
37
|
+
-B -b -c -D -E -e -F -I -i -J -L -l -m -O -o -p -Q -R -S -W -w]
|
|
38
|
+
@compreply = opts.select { |opt| opt.start_with?(cur) }
|
|
39
|
+
else
|
|
40
|
+
_ssh_complete_hosts(cur)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def _ssh_complete_hosts(cur)
|
|
45
|
+
@compreply = []
|
|
46
|
+
hosts = Set.new
|
|
47
|
+
|
|
48
|
+
# Parse ~/.ssh/config for Host entries
|
|
49
|
+
ssh_config = File.expand_path('~/.ssh/config')
|
|
50
|
+
if File.exist?(ssh_config)
|
|
51
|
+
begin
|
|
52
|
+
File.readlines(ssh_config).each do |line|
|
|
53
|
+
line = line.strip.downcase
|
|
54
|
+
if line.start_with?('host ')
|
|
55
|
+
# Skip patterns with wildcards
|
|
56
|
+
host_entries = line.sub(/^host\s+/, '').split
|
|
57
|
+
host_entries.each do |h|
|
|
58
|
+
hosts << h unless h.include?('*') || h.include?('?')
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
rescue Errno::EACCES
|
|
63
|
+
# Can't read file
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Parse /etc/hosts
|
|
68
|
+
if File.exist?('/etc/hosts')
|
|
69
|
+
begin
|
|
70
|
+
File.readlines('/etc/hosts').each do |line|
|
|
71
|
+
# Skip comments
|
|
72
|
+
line = line.split('#').first&.strip
|
|
73
|
+
next if line.nil? || line.empty?
|
|
74
|
+
|
|
75
|
+
parts = line.split(/\s+/)
|
|
76
|
+
next if parts.length < 2
|
|
77
|
+
|
|
78
|
+
# Add hostnames (skip IP address)
|
|
79
|
+
parts[1..].each { |h| hosts << h }
|
|
80
|
+
end
|
|
81
|
+
rescue Errno::EACCES
|
|
82
|
+
# Can't read file
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Parse ~/.ssh/known_hosts for hostnames
|
|
87
|
+
known_hosts = File.expand_path('~/.ssh/known_hosts')
|
|
88
|
+
if File.exist?(known_hosts)
|
|
89
|
+
begin
|
|
90
|
+
File.readlines(known_hosts).each do |line|
|
|
91
|
+
next if line.start_with?('#') || line.start_with?('@')
|
|
92
|
+
|
|
93
|
+
# First field is hostname/IP (may be hashed)
|
|
94
|
+
host_field = line.split[0]
|
|
95
|
+
next unless host_field
|
|
96
|
+
|
|
97
|
+
# Skip hashed entries
|
|
98
|
+
next if host_field.start_with?('|')
|
|
99
|
+
|
|
100
|
+
# May have multiple hosts comma-separated, with optional [host]:port
|
|
101
|
+
host_field.split(',').each do |h|
|
|
102
|
+
h = h.sub(/^\[/, '').sub(/\]:\d+$/, '') # Remove port notation
|
|
103
|
+
hosts << h unless h.match?(/^\d+\.\d+\.\d+\.\d+$/) # Skip bare IPs
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
rescue Errno::EACCES
|
|
107
|
+
# Can't read file
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
@compreply = hosts.to_a.select { |h| h.start_with?(cur) }.sort
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|