Bash completion in Ruby
Your auto-completion is kind-of complicated. You build it entirely in bash
with the assumption that it provides more perf (and it probably does... it probably isn't to-be honest... based on what I can gander from looking at it.) It's not maintainable even if it does have more perf. Why not use Ruby to generate the auto complete list for Ruby?
Let's take this from the start, we need to be able to let users do something like hello w
and get this is my list
, we don't need to be fancy and deal with returning relevant lists ourselves, lets leave that to compgen
we need to store a list of values and ship that list. So lets start with a hash:
list = {
"_reply" => [
"help",
"hello",
],
"help" => {
"_reply" => [
"hello",
]
},
"hello" => {
"_reply" => [
"help",
"--my-world",
"--no-my-world",
"world",
],
"help" => {
"_reply" => [
"world"
]
}
}
}
Every hash has a _reply
key with an array and every hash can have infinite other keys with hashes that each have their own _reply
, so we can dig deeply into sub-commands in a command system. We have no root key so that we can remain agnostic with our script and even alias our parent script and still get completion. We will then serialize that into bin/comp-list.pak
so that we can read and load it fast, msgpack
is far faster than JSON or YAML.
Now we need to pump out the lists to Bash so that it can do what it needs to do for us. Again, we do nothing fancy as we leave the sorting to compgen
. We can do that with this:
#!/usr/bin/env ruby
# Frozen-string-literal: true
# Copyright: 2016 Jordon Bedwell - MIT License
# Encoding: utf-8
ARGV.shift
require "msgpack"
file = File.join(__dir__, "comp-list.pak")
list = MessagePack.unpack(File.read(file))
# --
# Check if a key is included inside of given reply object.
# @param key [Any_] the string you are checking inside of the list.
# @param obj [Hash] the hash you wish to pull the `_reply` from.
# @return TrueClass|FalseClass true|false
# --
def key?(obj, key)
obj["_reply"].include?(key)
end
# --
# Checks to see if the key is partially within an array.
# @param obj [Hash] the hash you wish to pull the `_reply` from.
# @param key [Any_] the string grepping out of the list.
# @return TrueClass|FalseClass true|false
# --
def contains?(obj, key)
!obj["_reply"].grep(/#{Regexp.escape(key)}/).empty?
end
# --
# Check if a key is an opt (argument).
# @return TrueClass|FalseClass true|false
# @param key [Any_] the key.
# --
def opt?(key)
key =~ /\A-{1,2}/
end
# --
if ARGV.empty?
$stdout.puts list["_reply"].join(" ")
else
none = false
out = list
ARGV.each_with_index do |k, i|
if out.key?(k) then out = out[k]
elsif key?(out, k) && !opt?(k) then none = true
elsif i + 1 == ARGV.size && contains?(out, k) then next
elsif key?(out, k) && opt?(k) then next
else none = true
end
end
unless none
$stdout.puts out["_reply"].join(" ")
end
end
So, in short here is what we do:
bin\t\t
=>hello help
bin h\t\t
=>hello help
bin hello unknown\t\t
=>\n
bin hello\t\t
=>help --my-world --no-my-world world
bin hello --\t\t
=>--my-world --no-my-world
And in short, here is what the script does before compgen
:
bin\t\t
=>help hello
bin h\t\t
=>help hello
bin hello unknown\t\t
=>\n
bin hello\t\t
=>help --my-world --no-my-world world
bin hello --\t\t
=>help --my-world --no-my-world world
Now it's time to tie it into Bash and compgen
, so taking the list and the Ruby above and putting into all into a single file called bin/comp-list
we can then turn around and create bin/comp
with the following:
_bin() {
comp=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/comp-list
COMPREPLY=($(compgen -W "$($comp ${COMP_WORDS[@]})" -- \
${COMP_WORDS[COMP_CWORD]}))
}
complete -F _bin bin
# --
# Get the base.
# --
def base(const, skip = %w(help))
keys = const.all_commands.keys
return "_reply" => keys, "help" => {
"_reply" => keys - skip
}
end
# --
# Add the command options.
# --
def add_opts(out, const)
const.all_commands.each do |k, v|
v.options.map do |_, o|
out[k] ||= {
"_reply" => []
}
ary = out[k]["_reply"]
if o.boolean?
name = o.switch_name
ary.push("--no-#{name.gsub(/\A--/, "")}")
ary.push(name)
else
name = o.switch_name
ary.push("#{name}=")
end
end
end
out
end
# --
# Get a list of commands.
# --
def get_commands(const)
out = base(const)
if const.const_defined?(:Sub)
const.subcommands.each_with_object(out) do |k, o|
o[k] = send(__method__, const::Sub.const_get(k.capitalize))
end
end
add_opts(out, const)
end
# --
namespace :gen do
task :comp do
require "msgpack"
result = get_commands(My::CLI).to_msgpack
$stdout.puts(result)
end
end