2
\$\begingroup\$

Simple command-line option parser with automatic setting of variables and built-in error checking.

The story is I was working on another new project that had a lot of configurable options, as they usually do. I got tired of writing the same parser 50 different times, so I spent some time to write one that hopefully I could reuse.

It's nothing fancy, and I realized later of a more OO way to do this, but it worked for my purpose, and I'm looking forward to being done with the boring dry parsing bits forever. The end.

The syntax is simple: blah.exe [<option>=<value>, ...]

<option> is the name and <value> can be an integer, boolean, or string type.

As usual, any and all criticism welcome.

example usage

> program.exe bar=5 foo=2 bim=prog.conf baz=1
foo is 2
bar is 5
baz is 1
bim is "prog.conf"

example where bounds checking kicks in

> program.exe bar=5 foo=12 bim=prog.conf baz=1
error: value out of bounds, option #2 "foo=12"
exiting...

main.cpp

/*
    Author:  Mode77
    Date:    7/2/2023
*/

#include <string>
#include <iostream>
#include "cmdopt.h"

int foo;
int bar;
bool baz;
std::string bim;

int main(int argc, char *argv[])
{
    for(int i = 1; i < argc; i++) {
        if(intOption(argv[i], i, "foo", 0, 9, foo, argv[0])) { }
        else if(intOption(argv[i], i, "bar", 3, 5, bar, argv[0])) { }
        else if(boolOption(argv[i], i, "baz", baz, argv[0])) { }
        else if(stringOption(argv[i], i, "bim", bim, argv[0])) { }
        else unknownOptionError(argv[i], i);
    }
    std::cout << "foo is " << foo << "\n";
    std::cout << "bar is " << bar << "\n";
    std::cout << "baz is " << baz << "\n";
    std::cout << "bim is \"" << bim << "\"\n";
}

cmdopt.h

/*
    Author:  Mode77
    Date:    7/2/2023
*/

#pragma once

#include <string>



// Processes an integer-typed command-line option in three stages.
// First, the syntax of the assignment string is checked. If the syntax check
// fails, an error message is printed, and the program is halted and exited
// with EXIT_FAILURE.
//
// Otherwise, the option name in the assignment is checked against
// the given option name. If the name check fails, the function returns false.
//
// Finally, the value is parsed as an integer. If there is a parsing error
// (value is not a number, under/overflow, value outside of given range),
// an error message is printed, and the program is halted and exited
// with EXIT_FAILURE.
//
// If all checks pass, the destination variable is written with the parsed
// value and the function returns true.
//
// assignment:   Option assignment string
// optionNumber: Used for possible error messages
// optionName:   The name which the option is expected to match
// minimum:      The smallest value allowed for the option
// maximum:      The largest value allowed for the option
// destination:  The variable written to if the parse succeeds
// programName:  Used for possible error messages
bool intOption(
    std::string const & assignment,
    unsigned            optionNumber,
    std::string const & optionName,
    int                 minimum,
    int                 maximum,
    int               & destination,
    std::string const & programName);

// Processes a boolean-typed command-line option using the same three-stage
// process as intOption().
//
// The value is parsed as a boolean; 0 represents false and any other
// value is interpreted as true.
//
// The strings "true" and "false" are not allowed (Todo: YET!!!)
//
// assignment:   Option assignment string
// optionNumber: Used for possible error messages
// optionName:   The name which the option is expected to match
// destination:  The variable written to if the parse succeeds
// programName:  Used for possible error messages
bool boolOption(
    std::string const & assignment,
    unsigned            optionNumber,
    std::string const & optionName,
    bool              & destination,
    std::string const & programName);

// Processes a string-typed command-line option using the same three-stage
// process as intOption() and boolOption().
//
// assignment:   Option assignment string
// optionNumber: Used for possible error messages
// optionName:   The name which the option is expected to match
// destination:  The variable written to if the parse succeeds
// programName:  Used for possible error messages
bool stringOption(
    std::string const & assignment,
    unsigned            optionNumber,
    std::string const & optionName,
    std::string       & destination,
    std::string const & programName);

// Prints an unknown option error message and exits the program with the failure code
void unknownOptionError(std::string const &assignment, unsigned optionNumber);

cmdopt.cpp

/*
    Author:  Mode77
    Date:    7/2/2023
*/

#include <string>
#include <iostream>
#include <cstdlib>
#include <climits>
#include "cmdopt.h"

static bool isValidOptionSyntax(std::string const &);
static bool optionMatches(std::string const &, std::string const &);
static int expectInt(std::string const &, unsigned, int, int);
static void invalidSyntaxError(std::string const &, unsigned, std::string const &);
static void invalidValueError(std::string const &, unsigned);
static void valueOutOfBoundsError(std::string const &, unsigned);



bool intOption(
    std::string const & assignment,
    unsigned            optionNumber,
    std::string const & optionName,
    int                 minimum,
    int                 maximum,
    int               & destination,
    std::string const & programName)
{
    if(!isValidOptionSyntax(assignment)) {
        invalidSyntaxError(assignment, optionNumber, programName);
        return false; // Unreachable since the above will exit
    }
    if(!optionMatches(assignment, optionName)) {
        return false;
    }
    destination = expectInt(assignment, optionNumber, minimum, maximum);
    return true;
}



bool boolOption(
    std::string const & assignment,
    unsigned            optionNumber,
    std::string const & optionName,
    bool              & destination,
    std::string const & programName)
{
    if(!isValidOptionSyntax(assignment)) {
        invalidSyntaxError(assignment, optionNumber, programName);
        return false; // Unreachable since the above will exit
    }
    if(!optionMatches(assignment, optionName)) {
        return false;
    }
    destination = static_cast<bool>(expectInt(assignment, optionNumber, INT_MIN, INT_MAX));
    return true;
}



bool stringOption(
    std::string const & assignment,
    unsigned            optionNumber,
    std::string const & optionName,
    std::string       & destination,
    std::string const & programName)
{
    if(!isValidOptionSyntax(assignment)) {
        invalidSyntaxError(assignment, optionNumber, programName);
        return false; // Unreachable since the above will exit
    }
    if(!optionMatches(assignment, optionName)) {
        return false;
    }
    size_t const indexOfEquals = assignment.find('=');
    destination = assignment.substr(indexOfEquals + 1, std::string::npos);
    return true;
}



void unknownOptionError(std::string const &assignment, unsigned optionNumber)
{
    size_t const indexOfEquals = assignment.find('=');
    std::string const option(assignment.substr(0, indexOfEquals));
    std::cout <<
    "error: unknown option, "
    "option #" << optionNumber << " \"" << option << "\"\n";
    std::cout << "exiting...\n";
    exit(EXIT_FAILURE);
}



// Returns true if the option assignment string has the syntax:
// <option>=<value>
// where option and value are at least one character each.
// Returns false otherwise.
static bool isValidOptionSyntax(std::string const &assignment)
{
    size_t const indexOfEquals = assignment.find('=');

    bool const containsEqualsSign = indexOfEquals != std::string::npos;

    bool const hasAtLeastThreeCharacters = assignment.length() > 2;

    bool const equalsIsNotFirstOrLast =
        (indexOfEquals > 0) && (indexOfEquals < (assignment.length() - 1));

    return
        containsEqualsSign && hasAtLeastThreeCharacters && equalsIsNotFirstOrLast;
}



// Given a string in option assignment syntax, compares the assignment option
// with the given expected name.
//
// Returns true if they compare the same, false otherwise.
static bool optionMatches(std::string const &assignment, std::string const &expected)
{
    size_t const indexOfEquals = assignment.find('=');
    std::string const option(assignment.substr(0, indexOfEquals));
    return option == expected;
}



// Given a string in option assignment syntax, returns the value as an integer.
//
// If there was a parsing error (value is not a number, under/overflow, value is
// outside of given range), an error message is printed, and the program is halted
// and exited with EXIT_FAILURE.
static int expectInt(std::string const &assignment, unsigned optionNumber, int mini, int maxi)
{
    size_t const indexOfEquals = assignment.find('=');
    std::string const value(assignment.substr(indexOfEquals + 1, std::string::npos));
    int v;
    try {
        v = std::stoi(value);
    }
    catch(std::invalid_argument const &) {
        invalidValueError(assignment, optionNumber);
    }
    catch(std::out_of_range const &) {
        valueOutOfBoundsError(assignment, optionNumber);
    }
    if(v < mini || v > maxi) {
        valueOutOfBoundsError(assignment, optionNumber);
    }
    return v;
}



// Prints an invalid syntax error message and exits the program with the failure code
static void invalidSyntaxError(
    std::string const & assignment,
    unsigned            optionNumber,
    std::string const & programName)
{
    std::cout <<
    "error: invalid syntax, "
    "option #" << optionNumber << " \"" << assignment << "\"\n";
    std::cout << "usage: " << programName << " [<option>=<value>...]\n";
    std::cout << "exiting...\n";
    exit(EXIT_FAILURE);
}



// Prints an invalid value error message and exits the program with the failure code
static void invalidValueError(std::string const &assignment, unsigned optionNumber)
{
    std::cout <<
    "error: invalid value, "
    "option #" << optionNumber << " \"" << assignment << "\"\n";
    std::cout << "exiting...\n";
    exit(EXIT_FAILURE);
}



// Prints a value out of bounds error message and exits the program with the failure code
static void valueOutOfBoundsError(std::string const &assignment, unsigned optionNumber)
{
    std::cout <<
    "error: value out of bounds, "
    "option #" << optionNumber << " \"" << assignment << "\"\n";
    std::cout << "exiting...\n";
    exit(EXIT_FAILURE);
}
\$\endgroup\$
1
  • 2
    \$\begingroup\$ Micro review - you've non-portably lost the namespace qualification of std::size_t, std::exit and possible other identifiers. \$\endgroup\$ Commented Jul 8, 2023 at 7:33

1 Answer 1

3
\$\begingroup\$

Avoid code repetition

While you already reduced a lot of code by introducing functions like intOption(), boolOption() and so on, there's still a lot of repetition inside your main() function. In particular, you have to repeat else if (…, argv[i], i, …, argv[0])) {…}, where marks the only places where something (potentially) different is placed. Every time you have to repeat something, there is a chance you make a mistake, which at best causes a compilation error, but at worst results in hard to diagnose misbehavior at runtime.

To solve that though, we'll have to remove the whole if-else chain, which brings me to:

It's inefficient

Your example program only has 4 options, but what if you had hundreds of possible options? Then for each argument, it has to do hundreds of string comparisons. That could slow option parsing significantly. It would be nicer if we had some way to quickly look up the option from some table. Consider this:

std::unordered_map<std::string, std::function<void(std::string)>> options = {
    {"foo", [&](std::string value){ foo = std::stoi(value); },
    {"bar", [&](std::string value){ bar = std::stoi(value); },
    …
};

Then in main() your loop could look like:

for (int i = 1; i < argc; ++i) {
    auto [name, value] = splitAssignment(argv[i]);
    options[name](value);
}

Where splitAssignment() is a function that returns the name and value parts of an assignment string. Now, each line in the declaration of options still looks quite long, and it didn't have the range checks for allowed values. But you can make function that return lambdas, so you could rewrite intOption() and friends to return a lambda for you:

auto intOption(int minimum, int maximum, int& destination) {
    return [=, &destination](std::string value) {
        int v = std::stoi(value);
        if (v < minimum || v > maximum) {
            /* return error somehow */
        }
        destination = v;
    };
}

And then you can rewrite your table to:

std::unordered_map<std::string, std::function<void(std::string)>> options = {
    {"foo", intOption(0, 9, foo) },
    {"bar", intOption(3, 5, bar) },
    …
};

Now there really is no redundant information in each line of that table anymore.

Use exceptions to handle errors

Since all the errors we encounter here are indeed exceptional and will eventually cause us to exit the program, we can use exceptions. That means we don't have to pass programName and optionNumber down. For example, if we just throw a standard exception (or a custom one derived from it) whenever we encounter an error, then in the outermost loop we can write:

for (int i = 1; i < argc; ++i) {
    try {
        auto [name, value] = splitAssignment(argv[i]);
        options[name](value);
    } catch (std::exception& ex) {
        std::cerr << "Error parsing option #" << i << ": " << ex.what() << '\n'
                  << "Usage: " << argv[0] << " [<option>=<value>...]\n";
        std::exit(EXIT_FAILURE);
    }
}

Create a function for the outermost loop

You made lots of functions to help parse options, but you still have a loop you have to manually write in main(). You can easily make a function for that, so that your main() would look like:

int main(int argc, char* argv[]) {
    parseOptions(options, argc, argv);

    std::cout << "foo is " << foo << '\n';
    …
}

Use the Doxygen format to document your code

I see you added extensive comments to document your code. This is quite helpful, but with some minor changes it can be much better. If you use the Doxygen format, then the Doxygen tools can process your code and create hyperlinked HTML and PDF files containing the documentation. If you enable warnings, it can also warn you about functions and parameters you forgot to document. Finally, some code editors and IDEs might understand this format and use it to automatically add excerpts of your documentation into mouseover bubbles or tab completion popups.

\$\endgroup\$
1
  • 1
    \$\begingroup\$ Perhaps better to let exceptions in the loop bubble up to main() so that we can return rather than std::exit()? \$\endgroup\$ Commented Jul 8, 2023 at 15:52

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.