QPDFArgParser: support adding/printing help information

This commit is contained in:
Jay Berkenbilt 2022-01-07 17:01:10 -05:00
parent 5303130cf9
commit b4bd124be4
16 changed files with 541 additions and 59 deletions

View File

@ -19,7 +19,7 @@ def warn(*args, **kwargs):
class Main:
SOURCES = [whoami, 'job.yml']
SOURCES = [whoami, 'job.yml', 'manual/cli.rst']
DESTS = {
'decl': 'libqpdf/qpdf/auto_job_decl.hh',
'init': 'libqpdf/qpdf/auto_job_init.hh',
@ -87,6 +87,88 @@ class Main:
for k, v in hashes.items():
print(f'{k} {v}', file=f)
def generate_doc(self, df, f):
st_top = 0
st_topic = 1
st_option = 2
st_option_help = 3
state = st_top
indent = None
topic = None
option = None
short_text = None
long_text = None
print('this->ap.addHelpFooter("For detailed help, visit'
' the qpdf manual: https://qpdf.readthedocs.io\\n");', file=f)
def set_indent(x):
nonlocal indent
indent = ' ' * len(x)
def append_long_text(line):
nonlocal indent, long_text
if line == '\n':
long_text += '\n'
elif line.startswith(indent):
long_text += line[len(indent):]
else:
long_text = long_text.strip()
if long_text != '':
long_text += '\n'
return True
return False
lineno = 0
for line in df.readlines():
lineno += 1
if state == st_top:
m = re.match(r'^(\s*\.\. )help-topic (\S+): (.*)$', line)
if m:
set_indent(m.group(1))
topic = m.group(2)
short_text = m.group(3)
long_text = ''
state = st_topic
continue
m = re.match(r'^(\s*\.\. )qpdf:option:: (([^=\s]+)(=(\S+))?)$',
line)
if m:
if topic is None:
raise Exception('option seen before topic')
set_indent(m.group(1))
option = m.group(3)
synopsis = m.group(2)
if synopsis.endswith('`'):
raise Exception(
f'stray ` at end of option line (line {lineno})')
if synopsis != option:
long_text = synopsis + '\n'
else:
long_text = ''
state = st_option
continue
elif state == st_topic:
if append_long_text(line):
print(f'this->ap.addHelpTopic("{topic}", "{short_text}",'
f' R"({long_text})");', file=f)
state = st_top
elif state == st_option:
if line == '\n' or line.startswith(indent):
m = re.match(r'^(\s*\.\. )help: (.*)$', line)
if m:
set_indent(m.group(1))
short_text = m.group(2)
state = st_option_help
else:
state = st_top
elif state == st_option_help:
if append_long_text(line):
print(f'this->ap.addOptionHelp("{option}", "{topic}",'
f' "{short_text}", R"({long_text})");', file=f)
state = st_top
def generate(self):
warn(f'{whoami}: regenerating auto job files')
@ -230,6 +312,8 @@ class Main:
for j in ft['options']:
print('this->ap.copyFromOtherTable'
f'("{j}", "{other_table}");', file=f)
with open('manual/cli.rst', 'r') as df:
self.generate_doc(df, f)
if __name__ == '__main__':

View File

@ -30,6 +30,7 @@
#include <vector>
#include <functional>
#include <stdexcept>
#include <sstream>
// This is not a general-purpose argument parser. It is tightly
// crafted to work with qpdf. qpdf's command-line syntax is very
@ -38,7 +39,10 @@
// backward compatibility to ensure we don't break what constitutes a
// valid command. This class handles the quirks of qpdf's argument
// parsing, bash/zsh completion, and support for @argfile to read
// arguments from a file.
// arguments from a file. For the qpdf CLI, setup of QPDFArgParser is
// done mostly by automatically-generated code (one-off code for
// qpdf), though the handlers themselves are hand-coded. See
// generate_auto_job at the top of the source tree for details.
// Note about memory: there is code that expects argv to be a char*[],
// meaning that arguments are writable. Several operations, including
@ -119,6 +123,13 @@ class QPDFArgParser
std::string const& arg, param_arg_handler_t,
bool required, char const** choices);
// The default behavior when an invalid choice is specified with
// an option that takes choices is to list all the choices. This
// may not be good if there are too many choices, so you can
// provide your own handler in this case.
QPDF_DLL
void addInvalidChoiceHandler(std::string const& arg, param_arg_handler_t);
// If an option is shared among multiple tables and uses identical
// handlers, you can just copy it instead of repeating the
// registration call.
@ -131,6 +142,67 @@ class QPDFArgParser
QPDF_DLL
void addFinalCheck(bare_arg_handler_t);
// Help generation methods
// Help is available on topics and options. Options may be
// associated with topics. Users can run --help, --help=topic, or
// --help=--arg to get help. The top-level help tells the user how
// to run help and lists available topics. Help for a topic prints
// a short synopsis about the topic and lists any options that may
// be associated with the topic. Help for an option provides a
// short synopsis for that option. All help output is appended
// with a blurb (if supplied) directing the user to the full
// documentation. Help is not shown for options for which help has
// not been added. This makes it possible to have undocumented
// options for testing, backward-compatibility, etc. Also, it
// could be quite confusing to handle appropriate help for some
// inner options that may be repeated with different semantics
// inside different option tables. There is also no checking for
// whether an option that has help actually exists. In other
// words, it's up to the caller to ensure that help actually
// corresponds to the program's actual options. Rather than this
// being an intentional design decision, it is because this class
// is specifically for qpdf, qpdf generates its help and has other
// means to ensure consistency.
// Note about newlines:
//
// short_text should fit easily after the topic/option on the same
// line and should not end with a newline. Keep it to around 40 to
// 60 characters.
//
// long_text and footer should end with a single newline. They can
// have embedded newlines. Keep lines to under 80 columns.
//
// QPDFArgParser does reformat the text, but it may add blank
// lines in some situations. Following the above conventions will
// keep the help looking uniform.
// If provided, this footer is appended to all help, separated by
// a blank line.
QPDF_DLL
void addHelpFooter(std::string const&);
// Add a help topic along with the text for that topic
QPDF_DLL
void addHelpTopic(std::string const& topic,
std::string const& short_text,
std::string const& long_text);
// Add help for an option, and associate it with a topic.
QPDF_DLL
void addOptionHelp(std::string const& option_name,
std::string const& topic,
std::string const& short_text,
std::string const& long_text);
// Return the help text for a topic or option. Passing a null
// pointer returns the top-level help information. Passing an
// unknown value returns a string directing the user to run the
// top-level --help option.
QPDF_DLL
std::string getHelp(char const* topic_or_option);
// Convenience methods for adding member functions of a class as
// handlers.
template <class T>
@ -171,7 +243,8 @@ class QPDFArgParser
OptionEntry() :
parameter_needed(false),
bare_arg_handler(0),
param_arg_handler(0)
param_arg_handler(0),
invalid_choice_handler(0)
{
}
bool parameter_needed;
@ -179,9 +252,24 @@ class QPDFArgParser
std::set<std::string> choices;
bare_arg_handler_t bare_arg_handler;
param_arg_handler_t param_arg_handler;
param_arg_handler_t invalid_choice_handler;
};
typedef std::map<std::string, OptionEntry> option_table_t;
struct HelpTopic
{
HelpTopic() = default;
HelpTopic(std::string const& short_text, std::string const& long_text) :
short_text(short_text),
long_text(long_text)
{
}
std::string short_text;
std::string long_text;
std::set<std::string> options;
};
OptionEntry& registerArg(std::string const& arg);
void completionCommon(bool zsh);
@ -189,6 +277,7 @@ class QPDFArgParser
void argCompletionBash();
void argCompletionZsh();
void argHelp(char*);
void invalidHelpArg(char*);
void checkCompletion();
void handleArgFileArguments();
@ -202,6 +291,11 @@ class QPDFArgParser
option_table_t&, std::string const&, std::string const&);
void handleCompletion();
void getTopHelp(std::ostringstream&);
void getAllHelp(std::ostringstream&);
void getTopicHelp(
std::string const& name, HelpTopic const&, std::ostringstream&);
class Members
{
friend class QPDFArgParser;
@ -235,6 +329,9 @@ class QPDFArgParser
std::vector<PointerHolder<char>> bash_argv;
PointerHolder<char*> argv_ph;
PointerHolder<char*> bash_argv_ph;
std::map<std::string, HelpTopic> help_topics;
std::map<std::string, HelpTopic> option_help;
std::string help_footer;
};
PointerHolder<Members> m;
};

View File

@ -1,5 +1,6 @@
# Generated by generate_auto_job
generate_auto_job 019081046f1bc19f498134eae00344ecfc65b4e52442ee5f1bc80bff99689443
generate_auto_job 1f42fc554778d95210d11c44e858214b4854ead907d1c9ea84fe37f993ea1a23
job.yml 25c85cba1ae01dac9cd0f9cb7b734e7e3e531c0023ea2b892dc0d40bda1c1146
libqpdf/qpdf/auto_job_decl.hh 97395ecbe590b23ae04d6cce2080dbd0e998917ff5eeaa5c6aafa91041d3cd6a
libqpdf/qpdf/auto_job_init.hh 465bf46769559ceb77110d1b9d3293ba9b3595850b49848c31aeabd10aadb4ad
libqpdf/qpdf/auto_job_init.hh 2afffb5002ff28a3909f709709f65d77bf2289dd72d5ea3d1598a36664a49c73
manual/cli.rst f0109cca3366a9da4b0a05e3cce996ece2d776321a3f689aeaa2d6af599eee88

View File

@ -36,12 +36,15 @@ QPDFArgParser::QPDFArgParser(int argc, char* argv[], char const* progname_env) :
{
selectHelpOptionTable();
char const* help_choices[] = {"all", 0};
// More help choices are added dynamically.
addChoices(
"help", bindParam(&QPDFArgParser::argHelp, this), false, help_choices);
addInvalidChoiceHandler(
"help", bindParam(&QPDFArgParser::invalidHelpArg, this));
addBare("completion-bash",
std::bind(std::mem_fn(&QPDFArgParser::argCompletionBash), this));
bindBare(&QPDFArgParser::argCompletionBash, this));
addBare("completion-zsh",
std::bind(std::mem_fn(&QPDFArgParser::argCompletionZsh), this));
bindBare(&QPDFArgParser::argCompletionZsh, this));
selectMainOptionTable();
}
@ -157,6 +160,22 @@ QPDFArgParser::addChoices(
}
}
void
QPDFArgParser::addInvalidChoiceHandler(
std::string const& arg, param_arg_handler_t handler)
{
auto i = this->m->option_table->find(arg);
if (i == this->m->option_table->end())
{
QTC::TC("libtests", "QPDFArgParser invalid choice handler to unknown");
throw std::logic_error(
"QPDFArgParser: attempt to add invalid choice handler"
" to unknown argument");
}
auto& oe = i->second;
oe.invalid_choice_handler = handler;
}
void
QPDFArgParser::copyFromOtherTable(std::string const& arg,
std::string const& other_table)
@ -258,9 +277,17 @@ QPDFArgParser::argCompletionZsh()
}
void
QPDFArgParser::argHelp(char*)
QPDFArgParser::argHelp(char* p)
{
// QXXXQ
std::cout << getHelp(p);
exit(0);
}
void
QPDFArgParser::invalidHelpArg(char* p)
{
usage(std::string("unknown help option") +
(p ? (std::string(" ") + p) : ""));
}
void
@ -640,7 +667,14 @@ QPDFArgParser::parseArgs()
{
std::string message =
"--" + arg_s + " must be given as --" + arg_s + "=";
if (! oe.choices.empty())
if (oe.invalid_choice_handler)
{
oe.invalid_choice_handler(parameter);
// Method should call usage() or exit. Just in case it
// doesn't...
message += "option";
}
else if (! oe.choices.empty())
{
QTC::TC("libtests", "QPDFArgParser required choices");
message += "{";
@ -844,3 +878,166 @@ QPDFArgParser::handleCompletion()
}
exit(0);
}
void
QPDFArgParser::addHelpFooter(std::string const& text)
{
this->m->help_footer = "\n" + text;
}
void
QPDFArgParser::addHelpTopic(std::string const& topic,
std::string const& short_text,
std::string const& long_text)
{
if (topic == "all")
{
QTC::TC("libtests", "QPDFArgParser add reserved help topic");
throw std::logic_error(
"QPDFArgParser: can't register reserved help topic " + topic);
}
if (! ((topic.length() > 0) && (topic.at(0) != '-')))
{
QTC::TC("libtests", "QPDFArgParser bad topic for help");
throw std::logic_error(
"QPDFArgParser: help topics must not start with -");
}
if (this->m->help_topics.count(topic))
{
QTC::TC("libtests", "QPDFArgParser add existing topic");
throw std::logic_error(
"QPDFArgParser: topic " + topic + " has already been added");
}
this->m->help_topics[topic] = HelpTopic(short_text, long_text);
this->m->help_option_table["help"].choices.insert(topic);
}
void
QPDFArgParser::addOptionHelp(std::string const& option_name,
std::string const& topic,
std::string const& short_text,
std::string const& long_text)
{
if (! ((option_name.length() > 2) &&
(option_name.at(0) == '-') &&
(option_name.at(1) == '-')))
{
QTC::TC("libtests", "QPDFArgParser bad option for help");
throw std::logic_error(
"QPDFArgParser: options for help must start with --");
}
if (this->m->option_help.count(option_name))
{
QTC::TC("libtests", "QPDFArgParser duplicate option help");
throw std::logic_error(
"QPDFArgParser: option " + option_name + " already has help");
}
auto ht = this->m->help_topics.find(topic);
if (ht == this->m->help_topics.end())
{
QTC::TC("libtests", "QPDFArgParser add to unknown topic");
throw std::logic_error(
"QPDFArgParser: unable to add option " + option_name +
" to unknown help topic " + topic);
}
this->m->option_help[option_name] = HelpTopic(short_text, long_text);
ht->second.options.insert(option_name);
this->m->help_option_table["help"].choices.insert(option_name);
}
void
QPDFArgParser::getTopHelp(std::ostringstream& msg)
{
msg << "Run \"" << this->m->whoami
<< " --help=topic\" for help on a topic." << std::endl
<< "Run \"" << this->m->whoami
<< " --help=option\" for help on an option." << std::endl
<< "Run \"" << this->m->whoami
<< " --help=all\" to see all available help." << std::endl
<< std::endl
<< "Topics:" << std::endl;
for (auto const& i: this->m->help_topics)
{
msg << " " << i.first << ": " << i.second.short_text << std::endl;
}
}
void
QPDFArgParser::getAllHelp(std::ostringstream& msg)
{
getTopHelp(msg);
auto show = [this, &msg](std::map<std::string, HelpTopic>& topics,
std::string const& label) {
for (auto const& i: topics)
{
auto const& topic = i.first;
msg << std::endl
<< "== " << label << " " << topic
<< " (" << i.second.short_text << ") =="
<< std::endl
<< std::endl;
getTopicHelp(topic, i.second, msg);
}
};
show(this->m->help_topics, "topic");
show(this->m->option_help, "option");
msg << std::endl << "====" << std::endl;
}
void
QPDFArgParser::getTopicHelp(std::string const& name,
HelpTopic const& ht,
std::ostringstream& msg)
{
if (ht.long_text.empty())
{
msg << ht.short_text << std::endl;
}
else
{
msg << ht.long_text;
}
if (! ht.options.empty())
{
msg << std::endl << "Related options:" << std::endl;
for (auto const& i: ht.options)
{
msg << " " << i << ": "
<< this->m->option_help[i].short_text << std::endl;
}
}
}
std::string
QPDFArgParser::getHelp(char const* topic_or_option)
{
std::ostringstream msg;
if ((topic_or_option == nullptr) || (strlen(topic_or_option) == 0))
{
getTopHelp(msg);
}
else
{
std::string arg(topic_or_option);
if (arg == "all")
{
getAllHelp(msg);
}
else if (this->m->option_help.count(arg))
{
getTopicHelp(arg, this->m->option_help[arg], msg);
}
else if (this->m->help_topics.count(arg))
{
getTopicHelp(arg, this->m->help_topics[arg], msg);
}
else
{
// should not be possible
getTopHelp(msg);
}
}
msg << this->m->help_footer;
return msg.str();
}

View File

@ -162,3 +162,4 @@ this->ap.copyFromOtherTable("annotate", "128-bit encryption");
this->ap.copyFromOtherTable("form", "128-bit encryption");
this->ap.copyFromOtherTable("modify-other", "128-bit encryption");
this->ap.copyFromOtherTable("modify", "128-bit encryption");
this->ap.addHelpFooter("For detailed help, visit the qpdf manual: https://qpdf.readthedocs.io\n");

View File

@ -68,6 +68,18 @@ ArgParser::initOptions()
ap.addBare("sheep", [this](){ this->ap.selectOptionTable("sheep"); });
ap.registerOptionTable("sheep", nullptr);
ap.copyFromOtherTable("ewe", "baaa");
ap.addHelpFooter("For more help, read the manual.\n");
ap.addHelpTopic(
"quack", "Quack Options",
"Just put stuff after quack to get a count at the end.\n");
ap.addHelpTopic(
"baaa", "Baaa Options",
"Ewe can do sheepish things.\n"
"For example, ewe can add more ram to your computer.\n");
ap.addOptionHelp("--ewe", "baaa",
"just for ewe", "You are not a ewe.\n");
ap.addOptionHelp("--ram", "baaa", "curly horns", "");
}
void
@ -152,62 +164,60 @@ ArgParser::finalChecks()
void
ArgParser::test_exceptions()
{
try
{
auto err = [](char const* msg, std::function<void()> fn) {
try
{
fn();
assert(msg == nullptr);
}
catch (std::exception& e)
{
std::cout << msg << ": " << e.what() << std::endl;
}
};
err("duplicate handler", [this]() {
ap.selectMainOptionTable();
ap.addBare("potato", [](){});
assert(false);
}
catch (std::exception& e)
{
std::cout << "duplicate handler: " << e.what() << std::endl;
}
try
{
});
err("duplicate handler", [this]() {
ap.selectOptionTable("baaa");
ap.addBare("ram", [](){});
assert(false);
}
catch (std::exception& e)
{
std::cout << "duplicate handler: " << e.what() << std::endl;
}
try
{
});
err("duplicate table", [this]() {
ap.registerOptionTable("baaa", nullptr);
assert(false);
}
catch (std::exception& e)
{
std::cout << "duplicate table: " << e.what() << std::endl;
}
try
{
});
err("unknown table", [this]() {
ap.selectOptionTable("aardvark");
assert(false);
}
catch (std::exception& e)
{
std::cout << "unknown table: " << e.what() << std::endl;
}
try
{
});
err("copy from unknown table", [this]() {
ap.copyFromOtherTable("one", "two");
assert(false);
}
catch (std::exception& e)
{
std::cout << "copy from unknown table: " << e.what() << std::endl;
}
try
{
});
err("copy unknown from other table", [this]() {
ap.copyFromOtherTable("two", "baaa");
assert(false);
}
catch (std::exception& e)
{
std::cout << "copy unknown from other table: " << e.what() << std::endl;
}
});
err("add existing help topic", [this]() {
ap.addHelpTopic("baaa", "potato", "salad");
});
err("add reserved help topic", [this]() {
ap.addHelpTopic("all", "potato", "salad");
});
err("add to unknown topic", [this]() {
ap.addOptionHelp("--new", "oops", "potato", "salad");
});
err("bad option for help", [this]() {
ap.addOptionHelp("nodash", "baaa", "potato", "salad");
});
err("bad topic for help", [this]() {
ap.addHelpTopic("--dashes", "potato", "salad");
});
err("duplicate option help", [this]() {
ap.addOptionHelp("--ewe", "baaa", "potato", "salad");
});
err("invalid choice handler to unknown", [this]() {
ap.addInvalidChoiceHandler(
"elephant", [](char*){});
});
}
int main(int argc, char* argv[])

View File

@ -54,3 +54,10 @@ QPDFArgParser unrecognized 0
QPDFArgParser complete choices 0
QPDFArgParser copy from unknown 0
QPDFArgParser copy unknown 0
QPDFArgParser add reserved help topic 0
QPDFArgParser add existing topic 0
QPDFArgParser add to unknown topic 0
QPDFArgParser duplicate option help 0
QPDFArgParser bad option for help 0
QPDFArgParser bad topic for help 0
QPDFArgParser invalid choice handler to unknown 0

View File

@ -101,4 +101,30 @@ $td->runtest("args from stdin",
{$td->FILE => "stdin.out", $td->EXIT_STATUS => 0},
$td->NORMALIZE_NEWLINES);
$td->report(2 + (2 * scalar(@completion_tests)) + scalar(@arg_tests));
my @help_tests = (
'',
'=all',
'=--ewe',
'=quack',
);
foreach my $i (@help_tests)
{
my $out = $i;
$out =~ s/[=-]//g;
if ($out ne '')
{
$out = "-$out";
}
$td->runtest("--help$i",
{$td->COMMAND => "arg_parser --help$i"},
{$td->FILE => "help$out.out", $td->EXIT_STATUS => 0},
$td->NORMALIZE_NEWLINES);
}
$td->runtest("bad help option",
{$td->COMMAND => 'arg_parser --help=--oops'},
{$td->FILE => "help-bad.out", $td->EXIT_STATUS => 2},
$td->NORMALIZE_NEWLINES);
$td->report(3 + (2 * scalar(@completion_tests)) +
scalar(@arg_tests) + scalar(@help_tests));

View File

@ -2,7 +2,9 @@
--completion-zsh
--help
--help=
--help=--ewe
--help=all
--help=quack
--moo
--moo=
--oink=

View File

@ -1,5 +1,7 @@
--baaa
--completion-zsh
--help
--help=
--moo
--moo=
--oink=

View File

@ -4,3 +4,10 @@ duplicate table: QPDFArgParser: registering already registered option table baaa
unknown table: QPDFArgParser: selecting unregistered option table aardvark
copy from unknown table: QPDFArgParser: attempt to copy from unknown table two
copy unknown from other table: QPDFArgParser: attempt to copy unknown argument two from table baaa
add existing help topic: QPDFArgParser: topic baaa has already been added
add reserved help topic: QPDFArgParser: can't register reserved help topic all
add to unknown topic: QPDFArgParser: unable to add option --new to unknown help topic oops
bad option for help: QPDFArgParser: options for help must start with --
bad topic for help: QPDFArgParser: help topics must not start with -
duplicate option help: QPDFArgParser: option --ewe already has help
invalid choice handler to unknown: QPDFArgParser: attempt to add invalid choice handler to unknown argument

View File

@ -0,0 +1,32 @@
Run "arg_parser --help=topic" for help on a topic.
Run "arg_parser --help=option" for help on an option.
Run "arg_parser --help=all" to see all available help.
Topics:
baaa: Baaa Options
quack: Quack Options
== topic baaa (Baaa Options) ==
Ewe can do sheepish things.
For example, ewe can add more ram to your computer.
Related options:
--ewe: just for ewe
--ram: curly horns
== topic quack (Quack Options) ==
Just put stuff after quack to get a count at the end.
== option --ewe (just for ewe) ==
You are not a ewe.
== option --ram (curly horns) ==
curly horns
====
For more help, read the manual.

View File

@ -0,0 +1 @@
usage: unknown help option --oops

View File

@ -0,0 +1,3 @@
You are not a ewe.
For more help, read the manual.

View File

@ -0,0 +1,3 @@
Just put stuff after quack to get a count at the end.
For more help, read the manual.

View File

@ -0,0 +1,9 @@
Run "arg_parser --help=topic" for help on a topic.
Run "arg_parser --help=option" for help on an option.
Run "arg_parser --help=all" to see all available help.
Topics:
baaa: Baaa Options
quack: Quack Options
For more help, read the manual.