Skip to content

Commit b91ae6a

Browse files
committed
[main] Introduce cmdline option parser to share some code between executables
1 parent b556baf commit b91ae6a

File tree

6 files changed

+846
-123
lines changed

6 files changed

+846
-123
lines changed

main/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,3 +146,5 @@ else()
146146
DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT applications)
147147
endif()
148148
endif()
149+
150+
ROOT_ADD_TEST_SUBDIRECTORY(test)

main/src/optparse.hxx

Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
// \file optparse.hxx
2+
///
3+
/// Small utility to parse cmdline options.
4+
///
5+
/// Usage:
6+
/// ~~~{.cpp}
7+
/// MyAppOpts ParseArgs(const char **args, int nArgs) {
8+
/// ROOT::RCmdLineOpts opts;
9+
/// // will parse '-c VAL', '--compress VAL' or '--compress=VAL'
10+
/// opts.AddFlag({"-c", "--compress"}, RCmdLineOpts::EFlagType::kWithArg);
11+
/// // will toggle a boolean flag '--recreate' (no args).
12+
/// opts.AddFlag({"--recreate"});
13+
/// opts.AddFlag({"-o"}, RCmdLineOpts::EFlagType::kWithArg);
14+
///
15+
/// // NOTE: `args` should not contain the program name! It should usually be `argc + 1`.
16+
/// opts.Parse(args, nArgs);
17+
///
18+
/// // Check for errors:
19+
/// for (const auto &err : opts.GetErrors()) { /* print errors ... */ }
20+
/// if (!opts.GetErrors().empty()) return {};
21+
///
22+
/// // Convert the parsed options from string if necessary:
23+
/// MyAppOpts myOpts;
24+
/// // boolean flag:
25+
/// myOpts.fRecreate = opts.GetBooleanFlag("recreate");
26+
/// // string flag:
27+
/// myOpts.fOutput = opts.GetFlagValue("o");
28+
/// // integer flag:
29+
/// myOpts.fCompression = opts.GetFlagValueAs<int>("compress"); // (could also have used "c" instead of "compress")
30+
/// // positional arguments:
31+
/// myOpts.fArgs = opts.GetArgs();
32+
///
33+
/// return myOpts;
34+
/// }
35+
/// ~~~
36+
///
37+
/// ## Additional Notes
38+
/// If all the short flags you pass (those starting with a single `-`) are 1 character long, the parser will accept
39+
/// grouped flags like "-abc" as equivalent to "-a -b -c". The last flag in the group may also accept an argument, in
40+
/// which case "-abc foo" will count as "-a -b -c foo" where "foo" is the argument to "-c".
41+
///
42+
/// The string "--" is treated as the positional argument separator: all strings after it will be treated as positional
43+
/// arguments even if they start with "-".
44+
///
45+
/// \author Giacomo Parolini <[email protected]>
46+
/// \date 2025-10-09
47+
48+
#ifndef ROOT_OptParse
49+
#define ROOT_OptParse
50+
51+
#include <algorithm>
52+
#include <charconv>
53+
#include <cstring>
54+
#include <iostream>
55+
#include <optional>
56+
#include <sstream>
57+
#include <stdexcept>
58+
#include <string>
59+
#include <string_view>
60+
#include <vector>
61+
62+
namespace ROOT {
63+
64+
class RCmdLineOpts {
65+
public:
66+
enum class EFlagType {
67+
kBoolean,
68+
kWithArg
69+
};
70+
71+
struct RFlag {
72+
std::string fName;
73+
std::string fValue;
74+
};
75+
76+
private:
77+
std::vector<RFlag> fFlags;
78+
std::vector<std::string> fArgs;
79+
// If true, many short flags may be grouped: "-abc" == "-a -b -c".
80+
// This is automatically true if all short flags given are 1 character long, otherwise it's false.
81+
bool fAllowFlagGrouping = true;
82+
83+
struct RExpectedFlag {
84+
EFlagType fFlagType = EFlagType::kBoolean;
85+
std::string fName;
86+
std::string fHelp;
87+
// If >= 0, this flag is an alias of the RExpectedFlag at index fAlias.
88+
int fAlias = -1;
89+
bool fShort = false;
90+
91+
std::string AsStr() const { return std::string(fShort ? "-" : "--") + fName; }
92+
};
93+
std::vector<RExpectedFlag> fExpectedFlags;
94+
std::vector<std::string> fErrors;
95+
96+
const RExpectedFlag *GetExpectedFlag(std::string_view name) const
97+
{
98+
for (const auto &flag : fExpectedFlags) {
99+
if (flag.fName == name)
100+
return &flag;
101+
}
102+
return nullptr;
103+
}
104+
105+
public:
106+
const std::vector<std::string> &GetErrors() const { return fErrors; }
107+
const std::vector<std::string> &GetArgs() const { return fArgs; }
108+
const std::vector<RFlag> &GetFlags() const { return fFlags; }
109+
110+
/// Conveniency method to print any errors to `stream`.
111+
/// \return true if any error was printed
112+
bool ReportErrors(std::ostream &stream = std::cerr) const
113+
{
114+
for (const auto &err : fErrors)
115+
stream << err << "\n";
116+
return !fErrors.empty();
117+
}
118+
119+
void AddFlag(std::initializer_list<std::string_view> aliases, EFlagType type = EFlagType::kBoolean,
120+
std::string_view help = "")
121+
{
122+
int aliasIdx = -1;
123+
for (auto f : aliases) {
124+
auto prefixLen = f.find_first_not_of('-');
125+
if (prefixLen != 1 && prefixLen != 2)
126+
throw std::invalid_argument(std::string("Invalid flag `") + std::string(f) +
127+
"`: flags must start with '-' or '--'");
128+
if (f.size() == prefixLen)
129+
throw std::invalid_argument("Flag name cannot be empty");
130+
131+
fAllowFlagGrouping = fAllowFlagGrouping && (prefixLen > 1 || f.size() == 2);
132+
133+
RExpectedFlag expected;
134+
expected.fFlagType = type;
135+
expected.fName = f.substr(prefixLen);
136+
expected.fHelp = help;
137+
expected.fAlias = aliasIdx;
138+
expected.fShort = prefixLen == 1;
139+
fExpectedFlags.push_back(expected);
140+
if (aliasIdx < 0)
141+
aliasIdx = fExpectedFlags.size() - 1;
142+
}
143+
}
144+
145+
bool GetBooleanFlag(std::string_view name) const
146+
{
147+
const auto *exp = GetExpectedFlag(name);
148+
if (!exp)
149+
throw std::invalid_argument(std::string("Flag `") + std::string(name) + "` is not expected");
150+
if (exp->fFlagType != EFlagType::kBoolean)
151+
throw std::invalid_argument(std::string("Flag `") + std::string(name) + "` is not a boolean flag");
152+
153+
std::string_view lookedUpName = name;
154+
if (exp->fAlias >= 0)
155+
lookedUpName = fExpectedFlags[exp->fAlias].fName;
156+
157+
for (const auto &f : fFlags) {
158+
if (f.fName == lookedUpName)
159+
return true;
160+
}
161+
return false;
162+
}
163+
164+
std::string_view GetFlagValue(std::string_view name) const
165+
{
166+
const auto *exp = GetExpectedFlag(name);
167+
if (!exp)
168+
throw std::invalid_argument(std::string("Flag `") + std::string(name) + "` is not expected");
169+
if (exp->fFlagType != EFlagType::kWithArg)
170+
throw std::invalid_argument(std::string("Flag `") + std::string(name) +
171+
"` is a boolean flag, use GetBooleanFlag()");
172+
173+
std::string_view lookedUpName = name;
174+
if (exp->fAlias >= 0)
175+
lookedUpName = fExpectedFlags[exp->fAlias].fName;
176+
177+
for (const auto &f : fFlags) {
178+
if (f.fName == lookedUpName)
179+
return f.fValue;
180+
}
181+
return "";
182+
}
183+
184+
// Tries to retrieve the flag value as a type T.
185+
// The only supported types are integral and floating point types.
186+
// \return A value of type T if the flag is present and convertible
187+
// \return nullopt if the flag is not there
188+
// \throws std::invalid_argument if the flag is there but not convertible.
189+
template <typename T>
190+
std::optional<T> GetFlagValueAs(std::string_view name) const
191+
{
192+
static_assert(std::is_integral_v<T> || std::is_floating_point_v<T>);
193+
194+
if (auto val = GetFlagValue(name); !val.empty()) {
195+
T converted;
196+
auto res = std::from_chars(val.begin(), val.end(), converted);
197+
if (res.ptr == val.end() && res.ec == std::errc{}) {
198+
return converted;
199+
} else {
200+
std::stringstream err;
201+
err << "Failed to parse flag `" << name << "` with value `" << val << "`";
202+
if constexpr (std::is_integral_v<T>)
203+
err << " as an integer.\n";
204+
else
205+
err << " as a floating point number.\n";
206+
207+
if (res.ec == std::errc::result_out_of_range)
208+
throw std::out_of_range(err.str());
209+
else
210+
throw std::invalid_argument(err.str());
211+
}
212+
}
213+
return std::nullopt;
214+
}
215+
216+
void Parse(const char **args, std::size_t nArgs)
217+
{
218+
bool forcePositional = false;
219+
220+
std::vector<std::string_view> argStr;
221+
222+
for (std::size_t i = 0; i < nArgs && fErrors.empty(); ++i) {
223+
const char *arg = args[i];
224+
225+
if (strcmp(arg, "--") == 0) {
226+
forcePositional = true;
227+
continue;
228+
}
229+
230+
bool isFlag = !forcePositional && arg[0] == '-';
231+
if (isFlag) {
232+
++arg;
233+
// Parse long or short flag and its argument into `argStr` / `nxtArgStr`.
234+
// Note that `argStr` may contain multiple flags in case of grouped short flags (in which case nxtArgStr
235+
// refers only to the last one).
236+
argStr.clear();
237+
std::string_view nxtArgStr;
238+
bool nxtArgIsTentative = true;
239+
if (arg[0] == '-') {
240+
// long flag
241+
++arg;
242+
const char *eq = strchr(arg, '=');
243+
if (eq) {
244+
argStr.push_back(std::string_view(arg, eq - arg));
245+
nxtArgStr = std::string_view(eq + 1);
246+
nxtArgIsTentative = false;
247+
} else {
248+
argStr.push_back(std::string_view(arg));
249+
if (i < nArgs - 1 && args[i + 1][0] != '-') {
250+
nxtArgStr = args[i + 1];
251+
++i;
252+
}
253+
}
254+
} else {
255+
// short flag.
256+
// If flag grouping is active, all flags except the last one will have an implicitly empty argument.
257+
auto argLen = strlen(arg);
258+
while (fAllowFlagGrouping && argLen > 1) {
259+
argStr.push_back(std::string_view{arg, 1});
260+
++arg, --argLen;
261+
}
262+
263+
argStr.push_back(std::string_view(arg));
264+
if (i < nArgs - 1 && args[i + 1][0] != '-') {
265+
nxtArgStr = args[i + 1];
266+
++i;
267+
}
268+
}
269+
270+
for (auto j = 0u; j < argStr.size(); ++j) {
271+
std::string_view argS = argStr[j];
272+
const auto *exp = GetExpectedFlag(argS);
273+
if (!exp) {
274+
fErrors.push_back(std::string("Unknown flag: ") + args[j]);
275+
break;
276+
}
277+
278+
std::string_view nxtArg = (j == argStr.size() - 1) ? nxtArgStr : "";
279+
280+
RCmdLineOpts::RFlag flag;
281+
// If the flag is an alias (e.g. long version of a short one), save its name as the aliased one, so we
282+
// can fetch the value later by using any of the aliases.
283+
if (exp->fAlias < 0)
284+
flag.fName = argS;
285+
else
286+
flag.fName = fExpectedFlags[exp->fAlias].fName;
287+
288+
// Check for duplicate flags
289+
auto existingIt =
290+
std::find_if(fFlags.begin(), fFlags.end(), [&flag](const auto &f) { return f.fName == flag.fName; });
291+
if (existingIt != fFlags.end()) {
292+
std::string err = std::string("Flag ") + exp->AsStr() + " appeared more than once";
293+
if (exp->fFlagType == RCmdLineOpts::EFlagType::kWithArg)
294+
err += " with the value: " + existingIt->fValue;
295+
fErrors.push_back(err);
296+
break;
297+
}
298+
299+
// Check that arguments are what we expect.
300+
if (exp->fFlagType == RCmdLineOpts::EFlagType::kWithArg) {
301+
if (!nxtArg.empty()) {
302+
flag.fValue = nxtArg;
303+
} else {
304+
fErrors.push_back("Missing argument for flag " + exp->AsStr());
305+
}
306+
} else {
307+
if (!nxtArg.empty()) {
308+
if (nxtArgIsTentative)
309+
--i;
310+
else
311+
fErrors.push_back("Flag " + exp->AsStr() + " does not expect an argument");
312+
}
313+
}
314+
315+
if (!fErrors.empty())
316+
break;
317+
318+
fFlags.push_back(flag);
319+
}
320+
321+
if (!fErrors.empty())
322+
break;
323+
324+
} else {
325+
fArgs.push_back(arg);
326+
}
327+
}
328+
}
329+
};
330+
331+
} // namespace ROOT
332+
333+
#endif

0 commit comments

Comments
 (0)