Support bash completion using complete -C

This commit is contained in:
Jay Berkenbilt 2018-12-21 12:00:13 -05:00
parent 3c075fc017
commit dd1aca552c
19 changed files with 424 additions and 28 deletions

View File

@ -1,3 +1,8 @@
2018-12-21 Jay Berkenbilt <ejb@ql.org>
* You can now use eval $(qpdf --completion-bash) to enable bash
completion for qpdf. It's not perfect, but it works pretty well.
2018-12-19 Jay Berkenbilt <ejb@ql.org>
* When splitting pages using --split-pages, the outlines

View File

@ -322,7 +322,7 @@ class ArgParser
void argShowLinearization();
void argShowXref();
void argShowObject(char* parameter);
void argShowObject();
void argRawStreamData();
void argFilteredStreamData();
void argShowNpages();
void argShowPages();
@ -344,10 +344,16 @@ class ArgParser
void argEndEncrypt();
void usage(std::string const& message);
void checkCompletion();
void initOptionTable();
void handleHelpVersion();
void handleHelpArgs();
void handleArgFileArguments();
void handleBashArguments();
void readArgsFromFile(char const* filename);
void doFinalChecks();
void addOptionsToCompletions();
void addChoicesToCompletions(std::string const&);
void handleCompletion();
std::vector<PageSpec> parsePagesOptions();
void parseRotationParameter(std::string const&);
std::vector<int> parseNumrange(char const* range, int max,
@ -359,6 +365,12 @@ class ArgParser
char** argv;
Options& o;
int cur_arg;
bool bash_completion;
std::string bash_prev;
std::string bash_cur;
std::string bash_line;
size_t bash_point;
std::set<std::string> completions;
std::map<std::string, OptionEntry>* option_table;
std::map<std::string, OptionEntry> main_option_table;
@ -366,14 +378,18 @@ class ArgParser
std::map<std::string, OptionEntry> encrypt128_option_table;
std::map<std::string, OptionEntry> encrypt256_option_table;
std::vector<PointerHolder<char> > new_argv;
std::vector<PointerHolder<char> > bash_argv;
PointerHolder<char*> argv_ph;
PointerHolder<char*> bash_argv_ph;
};
ArgParser::ArgParser(int argc, char* argv[], Options& o) :
argc(argc),
argv(argv),
o(o),
cur_arg(0)
cur_arg(0),
bash_completion(false),
bash_point(0)
{
option_table = &main_option_table;
initOptionTable();
@ -496,7 +512,7 @@ ArgParser::initOptionTable()
(*t)["show-xref"] = oe_bare(&ArgParser::argShowXref);
(*t)["show-object"] = oe_requiredParameter(
&ArgParser::argShowObject, "obj[,gen]");
(*t)["raw-stream-data"] = oe_bare(&ArgParser::argShowObject);
(*t)["raw-stream-data"] = oe_bare(&ArgParser::argRawStreamData);
(*t)["filtered-stream-data"] = oe_bare(&ArgParser::argFilteredStreamData);
(*t)["show-npages"] = oe_bare(&ArgParser::argShowNpages);
(*t)["show-pages"] = oe_bare(&ArgParser::argShowPages);
@ -573,9 +589,30 @@ void
ArgParser::argEncrypt()
{
++cur_arg;
if (cur_arg + 3 >= argc)
if (cur_arg + 3 > argc)
{
usage("insufficient arguments to --encrypt");
if (this->bash_completion)
{
if (cur_arg == argc)
{
this->completions.insert("user-password");
}
else if (cur_arg + 1 == argc)
{
this->completions.insert("owner-password");
}
else if (cur_arg + 2 == argc)
{
this->completions.insert("40");
this->completions.insert("128");
this->completions.insert("256");
}
return;
}
else
{
usage("insufficient arguments to --encrypt");
}
}
o.user_password = argv[cur_arg++];
o.owner_password = argv[cur_arg++];
@ -904,7 +941,7 @@ ArgParser::argShowObject(char* parameter)
}
void
ArgParser::argShowObject()
ArgParser::argRawStreamData()
{
o.show_raw_stream_data = true;
}
@ -1098,14 +1135,55 @@ ArgParser::handleArgFileArguments()
argv[argc] = 0;
}
// Note: let's not be too noisy about documenting the fact that this
// software purposely fails to enforce the distinction between user
// and owner passwords. A user password is sufficient to gain full
// access to the PDF file, so there is nothing this software can do
// with an owner password that it couldn't do with a user password
// other than changing the /P value in the encryption dictionary.
// (Setting this value requires the owner password.) The
// documentation discusses this as well.
void
ArgParser::handleBashArguments()
{
// Do a minimal job of parsing bash_line into arguments. This
// doesn't do everything the shell does, but it should be good
// enough for purposes of handling completion. We can't use
// new_argv because this has to interoperate with @file arguments.
enum { st_top, st_quote } state = st_top;
std::string arg;
for (std::string::iterator iter = bash_line.begin();
iter != bash_line.end(); ++iter)
{
char ch = (*iter);
if ((state == st_top) && QUtil::is_space(ch) && (! arg.empty()))
{
bash_argv.push_back(
PointerHolder<char>(
true, QUtil::copy_string(arg.c_str())));
arg.clear();
}
else
{
if (ch == '"')
{
state = (state == st_top ? st_quote : st_top);
}
arg.append(1, ch);
}
}
if (bash_argv.empty())
{
// This can't happen if properly invoked by bash, but ensure
// we have a valid argv[0] regardless.
bash_argv.push_back(
PointerHolder<char>(
true, QUtil::copy_string(argv[0])));
}
// Explicitly discard any non-space-terminated word. The "current
// word" is handled specially.
bash_argv_ph = PointerHolder<char*>(true, new char*[1+bash_argv.size()]);
argv = bash_argv_ph.getPointer();
for (size_t i = 0; i < bash_argv.size(); ++i)
{
argv[i] = bash_argv.at(i).getPointer();
}
argc = static_cast<int>(bash_argv.size());
argv[argc] = 0;
}
char const* ArgParser::help = "\
\n\
@ -1127,6 +1205,7 @@ Basic Options\n\
--version show version of qpdf\n\
--copyright show qpdf's copyright and license information\n\
--help show command-line argument help\n\
--completion-bash output a bash complete command you can eval\n\
--password=password specify a password for accessing encrypted files\n\
--verbose provide additional informational output\n\
--progress give progress indicators while writing output\n\
@ -1412,7 +1491,15 @@ void usageExit(std::string const& msg)
void
ArgParser::usage(std::string const& message)
{
usageExit(message);
if (this->bash_completion)
{
// This will cause bash to fall back to regular file completion.
exit(0);
}
else
{
usageExit(message);
}
}
static JSON json_schema()
@ -1718,13 +1805,33 @@ ArgParser::readArgsFromFile(char const* filename)
}
void
ArgParser::handleHelpVersion()
ArgParser::handleHelpArgs()
{
// Make sure the output looks right on an 80-column display.
// Handle special-case informational options that are only
// available as the sole option.
if ((argc == 2) &&
((strcmp(argv[1], "--version") == 0) ||
(strcmp(argv[1], "-version") == 0)))
// The options processed here are also handled as a special case
// in handleCompletion.
if (argc != 2)
{
return;
}
char* arg = argv[1];
if (*arg != '-')
{
return;
}
++arg;
if (*arg == '-')
{
++arg;
}
if (! *arg)
{
return;
}
if (strcmp(arg, "version") == 0)
{
std::cout
<< whoami << " version " << QPDF::QPDFVersion() << std::endl
@ -1733,10 +1840,9 @@ ArgParser::handleHelpVersion()
exit(0);
}
if ((argc == 2) &&
((strcmp(argv[1], "--copyright") == 0) ||
(strcmp(argv[1], "-copyright") == 0)))
if (strcmp(arg, "copyright") == 0)
{
// Make sure the output looks right on an 80-column display.
// 1 2 3 4 5 6 7 8
// 12345678901234567890123456789012345678901234567890123456789012345678901234567890
std::cout
@ -1776,13 +1882,25 @@ ArgParser::handleHelpVersion()
exit(0);
}
if ((argc == 2) &&
((strcmp(argv[1], "--help") == 0) ||
(strcmp(argv[1], "-help") == 0)))
if (strcmp(arg, "help") == 0)
{
std::cout << help;
exit(0);
}
if (strcmp(arg, "completion-bash") == 0)
{
std::string path = argv[0];
size_t slash = path.find('/');
if ((slash != 0) && (slash != std::string::npos))
{
std::cerr << "WARNING: qpdf completion enabled"
<< " using relative path to qpdf" << std::endl;
}
std::cout << "complete -o bashdefault -o default -o nospace"
<< " -C " << argv[0] << " " << whoami << std::endl;
exit(0);
}
}
void
@ -1850,10 +1968,38 @@ ArgParser::parseRotationParameter(std::string const& parameter)
}
}
void
ArgParser::checkCompletion()
{
// See if we're being invoked from bash completion.
std::string bash_point_env;
if (QUtil::get_env("COMP_LINE", &bash_line) &&
QUtil::get_env("COMP_POINT", &bash_point_env))
{
int p = QUtil::string_to_int(bash_point_env.c_str());
if ((p > 0) && (p <= static_cast<int>(bash_line.length())))
{
// Point to the last character
bash_point = static_cast<size_t>(p) - 1;
}
if (argc >= 4)
{
bash_cur = argv[2];
bash_prev = argv[3];
handleBashArguments();
bash_completion = true;
}
}
}
void
ArgParser::parseOptions()
{
handleHelpVersion(); // QXXXQ calls std::cout
checkCompletion();
if (! this->bash_completion)
{
handleHelpArgs();
}
handleArgFileArguments();
for (cur_arg = 1; cur_arg < argc; ++cur_arg)
{
@ -1957,7 +2103,19 @@ ArgParser::parseOptions()
usage(std::string("unknown argument ") + arg);
}
}
if (this->bash_completion)
{
handleCompletion();
}
else
{
doFinalChecks();
}
}
void
ArgParser::doFinalChecks()
{
if (this->option_table != &(this->main_option_table))
{
usage("missing -- at end of options");
@ -2002,6 +2160,107 @@ ArgParser::parseOptions()
}
}
void
ArgParser::addChoicesToCompletions(std::string const& option)
{
if (this->option_table->count(option) != 0)
{
OptionEntry& oe = (*this->option_table)[option];
for (std::set<std::string>::iterator iter = oe.choices.begin();
iter != oe.choices.end(); ++iter)
{
completions.insert(*iter);
}
}
}
void
ArgParser::addOptionsToCompletions()
{
for (std::map<std::string, OptionEntry>::iterator iter =
this->option_table->begin();
iter != this->option_table->end(); ++iter)
{
std::string const& arg = (*iter).first;
OptionEntry& oe = (*iter).second;
std::string base = "--" + arg;
if (oe.param_arg_handler)
{
completions.insert(base + "=");
}
if (! oe.parameter_needed)
{
completions.insert(base);
}
}
}
void
ArgParser::handleCompletion()
{
if (this->completions.empty())
{
// Detect --option=... Bash treats the = as a word separator.
std::string choice_option;
if (bash_cur.empty() && (bash_prev.length() > 2) &&
(bash_prev.at(0) == '-') &&
(bash_prev.at(1) == '-') &&
(bash_line.at(bash_point) == '='))
{
choice_option = bash_prev.substr(2, std::string::npos);
}
else if ((bash_prev == "=") &&
(bash_line.length() > (bash_cur.length() + 1)))
{
// We're sitting at --option=x. Find previous option.
size_t end_mark = bash_line.length() - bash_cur.length() - 1;
char before_cur = bash_line.at(end_mark);
if (before_cur == '=')
{
size_t space = bash_line.find_last_of(' ', end_mark);
if (space != std::string::npos)
{
std::string candidate =
bash_line.substr(space + 1, end_mark - space - 1);
if ((candidate.length() > 2) &&
(candidate.at(0) == '-') &&
(candidate.at(1) == '-'))
{
choice_option =
candidate.substr(2, std::string::npos);
}
}
}
}
if (! choice_option.empty())
{
addChoicesToCompletions(choice_option);
}
else if ((! bash_cur.empty()) && (bash_cur.at(0) == '-'))
{
addOptionsToCompletions();
if (this->argc == 1)
{
// Handle options usually handled by handleHelpArgs.
this->completions.insert("--help");
this->completions.insert("--version");
this->completions.insert("--copyright");
this->completions.insert("--completion-bash");
}
}
}
for (std::set<std::string>::iterator iter = completions.begin();
iter != completions.end(); ++iter)
{
if (this->bash_cur.empty() ||
((*iter).substr(0, bash_cur.length()) == bash_cur))
{
std::cout << *iter << std::endl;
}
}
exit(0);
}
static void set_qpdf_options(QPDF& pdf, Options& o)
{
if (o.ignore_xref_streams)

View File

@ -100,6 +100,36 @@ $td->runtest("UTF-16 encoding errors",
{$td->FILE => "unicode-errors.out", $td->EXIT_STATUS => 0},
$td->NORMALIZE_NEWLINES);
my @completion_tests = (
['qpdf ', undef, 'top'],
['qpdf -', undef, 'top-arg'],
['qpdf --enc', undef, 'enc'],
['qpdf --encrypt ', undef, 'encrypt'],
['qpdf --encrypt u ', undef, 'encrypt-u'],
['qpdf --encrypt u o ', undef, 'encrypt-u-o'],
['qpdf @encrypt-u o ', undef, 'encrypt-u-o'],
['qpdf --encrypt u o 40 --', undef, 'encrypt-40'],
['qpdf --encrypt u o 128 --', undef, 'encrypt-128'],
['qpdf --encrypt u o 256 --', undef, 'encrypt-256'],
['qpdf --encrypt u o bad --', undef, 'encrypt-bad'],
['qpdf --split-pag', undef, 'split'],
['qpdf --decode-l', undef, 'decode-l'],
['qpdf --decode-lzzz', 15, 'decode-l'],
['qpdf --decode-level=', undef, 'decode-level'],
['qpdf --check -', undef, 'later-arg'],
);
$n_tests += scalar(@completion_tests);
foreach my $c (@completion_tests)
{
my ($cmd, $point, $description) = @$c;
my $out = "completion-$description.out";
$td->runtest("bash completion: $description",
{$td->COMMAND => [@{bash_completion($cmd, $point)}],
$td->FILTER => "perl filter-completion.pl $out"},
{$td->FILE => "$out", $td->EXIT_STATUS => 0},
$td->NORMALIZE_NEWLINES);
}
show_ntests();
# ----------
$td->notify("--- Argument Parsing ---");
@ -3144,6 +3174,24 @@ sub show_ntests
}
}
sub bash_completion
{
my ($line, $point) = @_;
if (! defined $point)
{
$point = length($line);
}
my $before_point = substr($line, 0, $point);
$before_point =~ m/^(.*)([ =])([^= ]*)$/ or die;
my ($first, $sep, $cur) = ($1, $2, $3);
my $prev = ($sep eq '=' ? $sep : $first);
$prev =~ s/.* (\S+)$/$1/;
my $this = $first;
$this =~ s/(\S+)\s.*/$1/;
['env', "COMP_LINE=$line", "COMP_POINT=$point",
"qpdf", $this, $cur, $prev];
}
sub check_pdf
{
my ($description, $command, $output, $status) = @_;

View File

@ -0,0 +1,2 @@
--decode-level=
!--help

View File

@ -0,0 +1,4 @@
all
generalized
none
!--help

View File

@ -0,0 +1 @@
--encrypt

View File

@ -0,0 +1,3 @@
--force-V4
!--annotate=
!--force-R5

View File

@ -0,0 +1,3 @@
--force-R5
!--annotate=
!--force-V4

View File

@ -0,0 +1,3 @@
--annotate=
!--force-R5
!--force-V4

View File

@ -0,0 +1,2 @@
!--help
!--print

View File

@ -0,0 +1,3 @@
128
256
40

View File

@ -0,0 +1 @@
owner-password

View File

@ -0,0 +1,2 @@
user-password
!--print

View File

@ -0,0 +1,7 @@
--check
--decode-level=
--encrypt
!--completion-bash
!--copyright
!--help
!--version

View File

@ -0,0 +1,2 @@
--split-pages
--split-pages=

View File

@ -0,0 +1,7 @@
--check
--completion-bash
--copyright
--decode-level=
--encrypt
--help
--version

View File

@ -0,0 +1,3 @@
!--copyright
!--help
!--version

View File

@ -0,0 +1,2 @@
--encrypt
u

View File

@ -0,0 +1,39 @@
use warnings;
use strict;
# Output every line from STDIN that appears in the file.
my %wanted = ();
my %notwanted = ();
my $f = $ARGV[0];
if (open(F, "<$f"))
{
while (<F>)
{
chomp;
if (s/^!//)
{
$notwanted{$_} = 1;
}
else
{
$wanted{$_} = 1;
}
}
close(F);
}
while (<STDIN>)
{
chomp;
if (exists $wanted{$_})
{
print $_, "\n";
}
elsif (exists $notwanted{$_})
{
delete $notwanted{$_};
}
}
foreach my $k (sort keys %notwanted)
{
print "!$k\n";
}