diff --git a/ChangeLog b/ChangeLog index aaf64d24..7818c98a 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,3 +1,8 @@ +2018-12-21 Jay Berkenbilt + + * 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 * When splitting pages using --split-pages, the outlines diff --git a/qpdf/qpdf.cc b/qpdf/qpdf.cc index 7b348f99..b1028e0e 100644 --- a/qpdf/qpdf.cc +++ b/qpdf/qpdf.cc @@ -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 parsePagesOptions(); void parseRotationParameter(std::string const&); std::vector 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 completions; std::map* option_table; std::map main_option_table; @@ -366,14 +378,18 @@ class ArgParser std::map encrypt128_option_table; std::map encrypt256_option_table; std::vector > new_argv; + std::vector > bash_argv; PointerHolder argv_ph; + PointerHolder 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( + 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( + true, QUtil::copy_string(argv[0]))); + } + // Explicitly discard any non-space-terminated word. The "current + // word" is handled specially. + bash_argv_ph = PointerHolder(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(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(bash_line.length()))) + { + // Point to the last character + bash_point = static_cast(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::iterator iter = oe.choices.begin(); + iter != oe.choices.end(); ++iter) + { + completions.insert(*iter); + } + } +} + +void +ArgParser::addOptionsToCompletions() +{ + for (std::map::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::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) diff --git a/qpdf/qtest/qpdf.test b/qpdf/qtest/qpdf.test index 3a1f30dd..8a5d2099 100644 --- a/qpdf/qtest/qpdf.test +++ b/qpdf/qtest/qpdf.test @@ -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) = @_; diff --git a/qpdf/qtest/qpdf/completion-decode-l.out b/qpdf/qtest/qpdf/completion-decode-l.out new file mode 100644 index 00000000..ca228636 --- /dev/null +++ b/qpdf/qtest/qpdf/completion-decode-l.out @@ -0,0 +1,2 @@ +--decode-level= +!--help diff --git a/qpdf/qtest/qpdf/completion-decode-level.out b/qpdf/qtest/qpdf/completion-decode-level.out new file mode 100644 index 00000000..776f1e1f --- /dev/null +++ b/qpdf/qtest/qpdf/completion-decode-level.out @@ -0,0 +1,4 @@ +all +generalized +none +!--help diff --git a/qpdf/qtest/qpdf/completion-enc.out b/qpdf/qtest/qpdf/completion-enc.out new file mode 100644 index 00000000..994ef604 --- /dev/null +++ b/qpdf/qtest/qpdf/completion-enc.out @@ -0,0 +1 @@ +--encrypt diff --git a/qpdf/qtest/qpdf/completion-encrypt-128.out b/qpdf/qtest/qpdf/completion-encrypt-128.out new file mode 100644 index 00000000..2c51a616 --- /dev/null +++ b/qpdf/qtest/qpdf/completion-encrypt-128.out @@ -0,0 +1,3 @@ +--force-V4 +!--annotate= +!--force-R5 diff --git a/qpdf/qtest/qpdf/completion-encrypt-256.out b/qpdf/qtest/qpdf/completion-encrypt-256.out new file mode 100644 index 00000000..7033fbab --- /dev/null +++ b/qpdf/qtest/qpdf/completion-encrypt-256.out @@ -0,0 +1,3 @@ +--force-R5 +!--annotate= +!--force-V4 diff --git a/qpdf/qtest/qpdf/completion-encrypt-40.out b/qpdf/qtest/qpdf/completion-encrypt-40.out new file mode 100644 index 00000000..3a184a6b --- /dev/null +++ b/qpdf/qtest/qpdf/completion-encrypt-40.out @@ -0,0 +1,3 @@ +--annotate= +!--force-R5 +!--force-V4 diff --git a/qpdf/qtest/qpdf/completion-encrypt-bad.out b/qpdf/qtest/qpdf/completion-encrypt-bad.out new file mode 100644 index 00000000..3732840b --- /dev/null +++ b/qpdf/qtest/qpdf/completion-encrypt-bad.out @@ -0,0 +1,2 @@ +!--help +!--print diff --git a/qpdf/qtest/qpdf/completion-encrypt-u-o.out b/qpdf/qtest/qpdf/completion-encrypt-u-o.out new file mode 100644 index 00000000..ad6bc9cf --- /dev/null +++ b/qpdf/qtest/qpdf/completion-encrypt-u-o.out @@ -0,0 +1,3 @@ +128 +256 +40 diff --git a/qpdf/qtest/qpdf/completion-encrypt-u.out b/qpdf/qtest/qpdf/completion-encrypt-u.out new file mode 100644 index 00000000..0d8a0622 --- /dev/null +++ b/qpdf/qtest/qpdf/completion-encrypt-u.out @@ -0,0 +1 @@ +owner-password diff --git a/qpdf/qtest/qpdf/completion-encrypt.out b/qpdf/qtest/qpdf/completion-encrypt.out new file mode 100644 index 00000000..4577a128 --- /dev/null +++ b/qpdf/qtest/qpdf/completion-encrypt.out @@ -0,0 +1,2 @@ +user-password +!--print diff --git a/qpdf/qtest/qpdf/completion-later-arg.out b/qpdf/qtest/qpdf/completion-later-arg.out new file mode 100644 index 00000000..33c129ff --- /dev/null +++ b/qpdf/qtest/qpdf/completion-later-arg.out @@ -0,0 +1,7 @@ +--check +--decode-level= +--encrypt +!--completion-bash +!--copyright +!--help +!--version diff --git a/qpdf/qtest/qpdf/completion-split.out b/qpdf/qtest/qpdf/completion-split.out new file mode 100644 index 00000000..12423c95 --- /dev/null +++ b/qpdf/qtest/qpdf/completion-split.out @@ -0,0 +1,2 @@ +--split-pages +--split-pages= diff --git a/qpdf/qtest/qpdf/completion-top-arg.out b/qpdf/qtest/qpdf/completion-top-arg.out new file mode 100644 index 00000000..0214970d --- /dev/null +++ b/qpdf/qtest/qpdf/completion-top-arg.out @@ -0,0 +1,7 @@ +--check +--completion-bash +--copyright +--decode-level= +--encrypt +--help +--version diff --git a/qpdf/qtest/qpdf/completion-top.out b/qpdf/qtest/qpdf/completion-top.out new file mode 100644 index 00000000..26ae7664 --- /dev/null +++ b/qpdf/qtest/qpdf/completion-top.out @@ -0,0 +1,3 @@ +!--copyright +!--help +!--version diff --git a/qpdf/qtest/qpdf/encrypt-u b/qpdf/qtest/qpdf/encrypt-u new file mode 100644 index 00000000..9d413960 --- /dev/null +++ b/qpdf/qtest/qpdf/encrypt-u @@ -0,0 +1,2 @@ +--encrypt +u diff --git a/qpdf/qtest/qpdf/filter-completion.pl b/qpdf/qtest/qpdf/filter-completion.pl new file mode 100644 index 00000000..a81178e2 --- /dev/null +++ b/qpdf/qtest/qpdf/filter-completion.pl @@ -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 () + { + chomp; + if (s/^!//) + { + $notwanted{$_} = 1; + } + else + { + $wanted{$_} = 1; + } + } + close(F); +} +while () +{ + chomp; + if (exists $wanted{$_}) + { + print $_, "\n"; + } + elsif (exists $notwanted{$_}) + { + delete $notwanted{$_}; + } +} +foreach my $k (sort keys %notwanted) +{ + print "!$k\n"; +}