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.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/.dockerignore +23 -0
  3. data/Dockerfile +54 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +39 -0
  6. data/Rakefile +12 -0
  7. data/lib/rubish/arithmetic.rb +140 -0
  8. data/lib/rubish/ast.rb +168 -0
  9. data/lib/rubish/builtins/arithmetic.rb +129 -0
  10. data/lib/rubish/builtins/bind_readline.rb +834 -0
  11. data/lib/rubish/builtins/directory_stack.rb +182 -0
  12. data/lib/rubish/builtins/echo_printf.rb +510 -0
  13. data/lib/rubish/builtins/hash_directories.rb +260 -0
  14. data/lib/rubish/builtins/read.rb +299 -0
  15. data/lib/rubish/builtins/trap.rb +324 -0
  16. data/lib/rubish/codegen.rb +1273 -0
  17. data/lib/rubish/completion.rb +840 -0
  18. data/lib/rubish/completions/bash_helpers.rb +530 -0
  19. data/lib/rubish/completions/git.rb +431 -0
  20. data/lib/rubish/completions/help_parser.rb +453 -0
  21. data/lib/rubish/completions/ssh.rb +114 -0
  22. data/lib/rubish/config.rb +267 -0
  23. data/lib/rubish/data/builtin_help.rb +716 -0
  24. data/lib/rubish/data/completion_data.rb +53 -0
  25. data/lib/rubish/data/readline_config.rb +47 -0
  26. data/lib/rubish/data/shell_options.rb +251 -0
  27. data/lib/rubish/data_define.rb +65 -0
  28. data/lib/rubish/execution_context.rb +1124 -0
  29. data/lib/rubish/expansion.rb +988 -0
  30. data/lib/rubish/history.rb +663 -0
  31. data/lib/rubish/lazy_loader.rb +127 -0
  32. data/lib/rubish/lexer.rb +1194 -0
  33. data/lib/rubish/parser.rb +1167 -0
  34. data/lib/rubish/prompt.rb +766 -0
  35. data/lib/rubish/repl.rb +2267 -0
  36. data/lib/rubish/runtime/builtins.rb +7222 -0
  37. data/lib/rubish/runtime/command.rb +1153 -0
  38. data/lib/rubish/runtime/job.rb +153 -0
  39. data/lib/rubish/runtime.rb +1169 -0
  40. data/lib/rubish/shell_state.rb +241 -0
  41. data/lib/rubish/startup_profiler.rb +67 -0
  42. data/lib/rubish/version.rb +5 -0
  43. data/lib/rubish.rb +60 -0
  44. data/sig/rubish.rbs +4 -0
  45. 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