Skip to content

Commit b841862

Browse files
committed
[main] optparse: add support for prefix flags
1 parent a996981 commit b841862

2 files changed

Lines changed: 274 additions & 15 deletions

File tree

main/src/optparse.hxx

Lines changed: 136 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -37,28 +37,50 @@
3737
/// ~~~
3838
///
3939
/// ## Additional Notes
40-
/// If all the short flags you pass (those starting with a single `-`) are 1 character long, the parser will accept
41-
/// grouped flags like "-abc" as equivalent to "-a -b -c". The last flag in the group may also accept an argument, in
42-
/// which case "-abc foo" will count as "-a -b -c foo" where "foo" is the argument to "-c".
4340
///
44-
/// Multiple repeated flags, like `-vvv`, are supported but must explicitly be marked as such:
41+
/// ### Flag grouping
42+
/// By default, if all the short flags you pass (those starting with a single `-`) are 1 character long, the parser will
43+
/// accept grouped flags like "-abc" as equivalent to "-a -b -c". The last flag in the group may also accept an
44+
/// argument, in which case "-abc foo" will count as "-a -b -c foo" where "foo" is the argument to "-c". If you want to
45+
/// disable flag grouping, use:
46+
///
47+
/// ~~~{.cpp}
48+
/// ROOT::RCmdLineOpts opts({ EFlagTreatment::kSimple });
49+
/// ~~~
50+
///
51+
/// ### Repeated flags
52+
/// Multiple repeated flags, like `-vvv`, are supported but must explicitly be marked as such on a per-flag basis:
4553
/// ~~~{.cpp}
4654
/// opts.AddFlag({"-v"}, RCmdLineOpts::EFlagType::kSwitch, "", RCmdLineOpts::kFlagAllowMultiple);
4755
/// ~~~
4856
/// This works both for switches and flags with arguments. `GetSwitch` returns the number of times a specific flag
4957
/// appeared; for flags with arguments `GetFlagValues` and `GetFlagValuesAs<T>` can be used to access the values as
5058
/// vectors.
5159
///
60+
/// ### Positional argument separator
5261
/// The string "--" is treated as the positional argument separator: all strings after it will be treated as positional
5362
/// arguments even if they start with "-".
5463
///
64+
/// ## Prefix flags (aka no space between flag and argument)
65+
/// If you need your flags to support the syntax "-fXYZ" where "-f" is your flag and "XYZ" its argument, you can enable
66+
/// that per-flag by using `RCmdLineOpts::kFlagPrefixArg`:
67+
///
68+
/// ~~~{.cpp}
69+
/// opts.AddFlag({"-I", "--include"}, RCmdLineOpts::EFlagType::kWithArg, RCmdLineOpts::kFlagPrefixArg });
70+
/// ~~~
71+
///
72+
/// (see EFlagTreatment for more details). This will **disable** flag grouping globally, but allows the parser to
73+
/// interpret flags and arguments that are not separated by spaces.
74+
/// Note that this only makes sense for flags with arguments.
75+
///
5576
/// \author Giacomo Parolini <giacomo.parolini@cern.ch>
5677
/// \date 2025-10-09
5778

5879
#ifndef ROOT_OptParse
5980
#define ROOT_OptParse
6081

6182
#include <algorithm>
83+
#include <cassert>
6284
#include <charconv>
6385
#include <cstring>
6486
#include <cstdint>
@@ -75,6 +97,22 @@ namespace ROOT {
7597

7698
class RCmdLineOpts {
7799
public:
100+
enum class EFlagTreatment {
101+
/// Will result to kGrouped if you don't define any short flag longer than 1 character, otherwise kSimple.
102+
kDefault,
103+
/// `-abc` will always be treated as the single flag "-abc"
104+
kSimple,
105+
/// `-abc` will be treated as "-a -b -c". This is only valid for short flags.
106+
/// With this setting you cannot define short flags that are more than 1 character long nor ones that are marked
107+
/// with kFlagPrefixArg.
108+
kGrouped,
109+
};
110+
111+
struct RSettings {
112+
/// Affects how flags are parsed (\see EFlagTreatment).
113+
EFlagTreatment fFlagTreatment;
114+
};
115+
78116
enum class EFlagType {
79117
kSwitch,
80118
kWithArg
@@ -90,15 +128,15 @@ public:
90128
enum EFlagOpt {
91129
/// Flag is allowed to appear multiple times (default: it's an error to see the same flag twice)
92130
kFlagAllowMultiple = 1 << 0,
131+
/// Flag argument can appear right after this flag without a space or equal sign in between.
132+
/// Note that marking any short flag with this disables flag grouping!
133+
kFlagPrefixArg = 1 << 1,
93134
};
94135

95136
private:
137+
RSettings fSettings;
96138
std::vector<RFlag> fFlags;
97139
std::vector<std::string> fArgs;
98-
// If true, many short flags may be grouped: "-abc" == "-a -b -c".
99-
// This is automatically true if all short flags given are 1 character long, otherwise it's false.
100-
// (a short flag is a flag with a single `-` as its prefix).
101-
bool fAllowFlagGrouping = true;
102140

103141
struct RExpectedFlag {
104142
EFlagType fFlagType = EFlagType::kSwitch;
@@ -116,14 +154,27 @@ private:
116154

117155
const RExpectedFlag *GetExpectedFlag(std::string_view name) const
118156
{
157+
const auto StartsWith = [](std::string_view string, std::string_view prefix) {
158+
return string.size() >= prefix.size() && string.substr(0, prefix.size()) == prefix;
159+
};
160+
119161
for (const auto &flag : fExpectedFlags) {
120-
if (flag.fName == name)
162+
if (flag.fOpts & kFlagPrefixArg) {
163+
if (StartsWith(name, flag.fName)) {
164+
// NOTE: we can't have ambiguities here because we make sure that no flags share a common prefix in
165+
// AddFlag().
166+
return &flag;
167+
}
168+
} else if (flag.fName == name) {
121169
return &flag;
170+
}
122171
}
123172
return nullptr;
124173
}
125174

126175
public:
176+
explicit RCmdLineOpts(RSettings settings = {EFlagTreatment::kDefault}) : fSettings(settings) {}
177+
127178
/// Returns all parsing errors
128179
const std::vector<std::string> &GetErrors() const { return fErrors; }
129180
/// Retrieves all positional arguments
@@ -142,6 +193,7 @@ public:
142193

143194
/// Defines a new flag (either a switch or a flag with argument).
144195
/// The flag may be referred to as any of the values inside `aliases` (e.g. { "-h", "--help" }).
196+
/// You must pass at least 1 string inside `aliases`.
145197
/// All strings inside `aliases` must start with `-` or `--` and be at least 1 character long (aside the dashes).
146198
/// Flags starting with a single `-` are considered "short", regardless of their actual length.
147199
/// If all short flags are 1 character long, they may be collapsed into one and parsed as individual flags
@@ -155,6 +207,17 @@ public:
155207
void AddFlag(std::initializer_list<std::string_view> aliases, EFlagType type = EFlagType::kSwitch,
156208
std::string_view help = "", std::uint32_t flagOpts = 0)
157209
{
210+
const auto IsPrefixOf = [](std::string_view a, std::string_view b) {
211+
return a.size() < b.size() && std::equal(a.begin(), a.end(), b.begin());
212+
};
213+
214+
if (aliases.size() == 0)
215+
throw std::invalid_argument("AddFlag must receive at least 1 name for the flag!");
216+
217+
if ((flagOpts & kFlagPrefixArg) && type != EFlagType::kWithArg)
218+
throw std::invalid_argument("Flag `" + std::string(*aliases.begin()) +
219+
"` has option kFlagPrefixArg but it's a Switch, so the option makes no sense.");
220+
158221
int aliasIdx = -1;
159222
for (auto f : aliases) {
160223
auto prefixLen = f.find_first_not_of('-');
@@ -164,11 +227,43 @@ public:
164227
if (f.size() == prefixLen)
165228
throw std::invalid_argument("Flag name cannot be empty");
166229

167-
fAllowFlagGrouping = fAllowFlagGrouping && (prefixLen > 1 || f.size() == 2);
230+
auto flagName = f.substr(prefixLen);
231+
232+
// Check that we're not introducing ambiguities with prefix flags. While we're at it, also check that none
233+
// of the given aliases were already added.
234+
for (const auto &expFlag : fExpectedFlags) {
235+
// NOTE: we're checking against the full string, not just the flag name, to allow cases like:
236+
// AddFlag({"-foo", "--foo"}).
237+
if (expFlag.AsStr() == f)
238+
throw std::invalid_argument("Flag `" + expFlag.AsStr() + "` was added multiple times.");
239+
240+
if (!(flagOpts & kFlagPrefixArg) && !(expFlag.fOpts & kFlagPrefixArg))
241+
continue;
242+
243+
// At least one of expFlag and f is a prefix flag: this means that they must not share a common prefix.
244+
if (((expFlag.fOpts & kFlagPrefixArg) && IsPrefixOf(expFlag.fName, flagName)) ||
245+
((flagOpts & kFlagPrefixArg) && IsPrefixOf(flagName, expFlag.fName))) {
246+
throw std::invalid_argument("Flags `" + expFlag.AsStr() + "` and `" + std::string(f) +
247+
"` have a common prefix. This causes ambiguity because at least one of them "
248+
"is marked with kFlagPrefixArg.");
249+
}
250+
}
251+
252+
bool disallowsGrouping = (prefixLen == 1 && (f.size() > 2 || (flagOpts & kFlagPrefixArg)));
253+
if (disallowsGrouping) {
254+
if (fSettings.fFlagTreatment == EFlagTreatment::kDefault) {
255+
fSettings.fFlagTreatment = EFlagTreatment::kSimple;
256+
} else if (fSettings.fFlagTreatment == EFlagTreatment::kGrouped) {
257+
throw std::invalid_argument(
258+
std::string("Flags starting with a single dash must be 1 character long when `FlagTreatment == "
259+
"EFlagTreatment::kGrouped'! Cannot accept given flag `") +
260+
std::string(f) + "`");
261+
}
262+
}
168263

169264
RExpectedFlag expected;
170265
expected.fFlagType = type;
171-
expected.fName = f.substr(prefixLen);
266+
expected.fName = flagName;
172267
expected.fHelp = help;
173268
expected.fAlias = aliasIdx;
174269
expected.fShort = prefixLen == 1;
@@ -218,8 +313,7 @@ public:
218313
if (!exp)
219314
throw std::invalid_argument(std::string("Flag `") + std::string(name) + "` is not expected");
220315
if (exp->fFlagType != EFlagType::kWithArg)
221-
throw std::invalid_argument(std::string("Flag `") + std::string(name) +
222-
"` is a switch, use GetSwitch()");
316+
throw std::invalid_argument(std::string("Flag `") + std::string(name) + "` is a switch, use GetSwitch()");
223317

224318
std::string_view lookedUpName = name;
225319
if (exp->fAlias >= 0)
@@ -312,6 +406,11 @@ public:
312406
{
313407
bool forcePositional = false;
314408

409+
// If flag treatment is still Default by now it means we can safely group short flags (otherwise we'd have
410+
// already changed it to Simple).
411+
if (fSettings.fFlagTreatment == EFlagTreatment::kDefault)
412+
fSettings.fFlagTreatment = EFlagTreatment::kGrouped;
413+
315414
// Contains one or more flags coming from one of the arguments (e.g. "-abc" may be split
316415
// into flags "a", "b", and "c", which will be stored in `argStr`).
317416
std::vector<std::string_view> argStr;
@@ -336,6 +435,7 @@ public:
336435
// refers only to the last one).
337436
argStr.clear();
338437
std::string_view nxtArgStr;
438+
// If this is false `nxtArgStr` *must* refer to the next arg, otherwise it might or might not be.
339439
bool nxtArgIsTentative = true;
340440
if (arg[0] == '-') {
341441
// long flag
@@ -356,7 +456,7 @@ public:
356456
// short flag.
357457
// If flag grouping is active, all flags except the last one will have an implicitly empty argument.
358458
auto argLen = strlen(arg);
359-
while (fAllowFlagGrouping && argLen > 1) {
459+
while (fSettings.fFlagTreatment == EFlagTreatment::kGrouped && argLen > 1) {
360460
argStr.push_back(std::string_view{arg, 1});
361461
++arg, --argLen;
362462
}
@@ -370,20 +470,41 @@ public:
370470

371471
for (auto j = 0u; j < argStr.size(); ++j) {
372472
std::string_view argS = argStr[j];
473+
373474
const auto *exp = GetExpectedFlag(argS);
374475
if (!exp) {
375476
fErrors.push_back(std::string("Unknown flag: ") + argOrig);
376477
break;
377478
}
378479

480+
// In Prefix mode, check if the returned expected flag is shorter than `argS`. This can mean two things:
481+
// - if `nxtArgIsTentative == false` then this flag was followed by an equal sign, and in that case
482+
// the intention is interpreted as "I want this flag's argument to be whatever follows the equal sign",
483+
// which means we treat this as an unknown flag;
484+
// - otherwise, we use the rest of `argS` as the argument to the flag.
485+
// More concretely: if the user added flag "-D" and argS is "-Dfoo=bar", we parse it as
486+
// {flag: "-Dfoo", arg: "bar"}, rather than {flag: "-D", arg: "foo=bar"}.
487+
if ((exp->fOpts & kFlagPrefixArg) && argS.size() > exp->fName.size()) {
488+
if (nxtArgIsTentative) {
489+
i -= !nxtArgStr.empty(); // if we had already picked a candidate next arg, undo that.
490+
nxtArgStr = argS.substr(exp->fName.size());
491+
nxtArgIsTentative = false;
492+
} else {
493+
fErrors.push_back(std::string("Unknown flag: ") + argOrig);
494+
break;
495+
}
496+
} else {
497+
assert(exp->fName.size() == argS.size());
498+
}
499+
379500
std::string_view nxtArg = (j == argStr.size() - 1) ? nxtArgStr : "";
380501

381502
RCmdLineOpts::RFlag flag;
382503
flag.fHelp = exp->fHelp;
383504
// If the flag is an alias (e.g. long version of a short one), save its name as the aliased one, so we
384505
// can fetch the value later by using any of the aliases.
385506
if (exp->fAlias < 0)
386-
flag.fName = argS;
507+
flag.fName = exp->fName;
387508
else
388509
flag.fName = fExpectedFlags[exp->fAlias].fName;
389510

0 commit comments

Comments
 (0)