1 /// Contains a type that can parse data into a command. 2 module jaster.cli.commandparser; 3 4 import std.traits, std.algorithm, std.conv, std.format, std.typecons; 5 import jaster.cli.infogen, jaster.cli.binder, jaster.cli.result, jaster.cli.parser; 6 7 /++ 8 + A type that can parse an argument list into a command. 9 + 10 + Description: 11 + One may wonder, "what's the difference between `CommandParser` and `CommandLineInterface`?". 12 + 13 + The answer is simple: `CommandParser` $(B only) performs argument parsing and value binding for a single command, 14 + whereas `CommandLineInterface` builds on top of `CommandParser` and several other components in order to support 15 + multiple commands via a complete CLI interface. 16 + 17 + So in short, if all you want from JCLI is its command modeling and parsing abilties without all the extra faff 18 + provided by `CommandLineInterface`, and you're fine with performing execution by yourself, then you'll want to use 19 + this type. 20 + 21 + Commands_: 22 + Commands and arguments are defined in the same way as `CommandLineInterface` documents. 23 + 24 + However, you don't need to define an `onExecute` function as this type has no concept of executing commands, only parsing them. 25 + 26 + Dependency_Injection: 27 + This is a feature provided by `CommandLineInterface`, not `CommandParser`. 28 + 29 + Command instances must be constructed outside of `CommandParser`, as it has no knowledge on how to do this, it only knows how to parse data into it. 30 + 31 + Params: 32 + CommandT = The type of your command. 33 + ArgBinderInstance = The `ArgBinder` to use when binding arguments to the user provided values. 34 + ++/ 35 struct CommandParser(alias CommandT, alias ArgBinderInstance = ArgBinder!()) 36 { 37 /// The `CommandInfo` for the command being parsed. Special note is that this is compile-time viewable. 38 enum Info = getCommandInfoFor!(CommandT, ArgBinderInstance); 39 40 private static struct ArgRuntimeInfo(ArgInfoT) 41 { 42 ArgInfoT argInfo; 43 bool wasFound; 44 45 bool isNullable() 46 { 47 return (this.argInfo.existence & CommandArgExistence.optional) > 0; 48 } 49 } 50 private auto argInfoOf(ArgInfoT)(ArgInfoT info) { return ArgRuntimeInfo!ArgInfoT(info); } 51 52 /// Same as `parse` except it will automatically construct an `ArgPullParser` for you. 53 Result!void parse(string[] args, ref CommandT commandInstance) 54 { 55 auto parser = ArgPullParser(args); 56 return this.parse(parser, commandInstance); 57 } 58 59 /++ 60 + Parses the given arguments into your command instance. 61 + 62 + Description: 63 + This performs the full value parsing as described in `CommandLineInterface`. 64 + 65 + Notes: 66 + If the argument parsing fails, your command instance and parser $(B can be in a half-parsed state). 67 + 68 + Params: 69 + parser = The parser containing the argument tokens. 70 + commandInstance = The instance of your `CommandT` to populate. 71 + 72 + Returns: 73 + A successful result (`Result.isSuccess`) if argument parsing and binding succeeded, otherwise a failure result 74 + with an error (`Result.asFailure.error`) describing what happened. This error is user-friendly. 75 + 76 + See_Also: 77 + `jaster.cli.core.CommandLineInterface` as it goes over everything in detail. 78 + 79 + This project's README also goes into detail about how commands are parsed. 80 + ++/ 81 Result!void parse(ref ArgPullParser parser, ref CommandT commandInstance) 82 { 83 auto namedArgs = this.getNamedArgs(); 84 auto positionalArgs = this.getPositionalArgs(); 85 86 size_t positionalArgIndex = 0; 87 bool breakOuterLoop = false; 88 for(; !parser.empty && !breakOuterLoop; parser.popFront()) 89 { 90 const token = parser.front(); 91 final switch(token.type) with(ArgTokenType) 92 { 93 case None: assert(false); 94 case EOF: break; 95 96 // Positional Argument 97 case Text: 98 if(positionalArgIndex >= positionalArgs.length) 99 { 100 return typeof(return).failure( 101 "too many arguments starting at '%s'".format(token.value) 102 ); 103 } 104 105 auto actionResult = positionalArgs[positionalArgIndex].argInfo.actionFunc(token.value, commandInstance); 106 positionalArgs[positionalArgIndex++].wasFound = true; 107 108 if(!actionResult.isSuccess) 109 { 110 return typeof(return).failure( 111 "positional argument %s ('%s'): %s" 112 .format(positionalArgIndex-1, positionalArgs[positionalArgIndex-1].argInfo.uda.name, actionResult.asFailure.error) 113 ); 114 } 115 break; 116 117 // Named Argument 118 case LongHandArgument: 119 if(token.value == "-" || token.value == "") // --- || -- 120 { 121 breakOuterLoop = true; 122 static if(!Info.rawListArg.isNull) 123 mixin("commandInstance.%s = parser.unparsedArgs;".format(Info.rawListArg.get.identifier)); 124 break; 125 } 126 goto case; 127 case ShortHandArgument: 128 const argIndex = namedArgs.countUntil!"a.argInfo.uda.pattern.matchSpaceless(b)"(token.value); 129 if(argIndex < 0) 130 return typeof(return).failure("unknown argument '%s'".format(token.value)); 131 132 if(namedArgs[argIndex].wasFound && (namedArgs[argIndex].argInfo.existence & CommandArgExistence.multiple) == 0) 133 return typeof(return).failure("multiple definitions of argument '%s'".format(token.value)); 134 135 namedArgs[argIndex].wasFound = true; 136 auto argParseResult = this.performParseScheme(parser, commandInstance, namedArgs[argIndex].argInfo); 137 if(!argParseResult.isSuccess) 138 return typeof(return).failure("named argument '%s': ".format(token.value)~argParseResult.asFailure.error); 139 break; 140 } 141 } 142 143 auto validateResult = this.validateArgs(namedArgs, positionalArgs); 144 return validateResult; 145 } 146 147 private Result!void performParseScheme(ref ArgPullParser parser, ref CommandT commandInstance, NamedArgumentInfo!CommandT argInfo) 148 { 149 final switch(argInfo.parseScheme) with(CommandArgParseScheme) 150 { 151 case default_: return this.parseDefault(parser, commandInstance, argInfo); 152 case allowRepeatedName: return this.parseRepeatableName(parser, commandInstance, argInfo); 153 case bool_: return this.parseBool(parser, commandInstance, argInfo); 154 } 155 } 156 157 private Result!void parseBool(ref ArgPullParser parser, ref CommandT commandInstance, NamedArgumentInfo!CommandT argInfo) 158 { 159 // Bools have special support: 160 // If they are defined, they are assumed to be true, however: 161 // If the next token is Text, and its value is one of a predefined list, then it is then sent to the ArgBinder instead of defaulting to true. 162 163 auto parserCopy = parser; 164 parserCopy.popFront(); 165 166 if(parserCopy.empty 167 || parserCopy.front.type != ArgTokenType.Text 168 || !["true", "false"].canFind(parserCopy.front.value)) 169 return argInfo.actionFunc("true", /*ref*/ commandInstance); 170 171 auto result = argInfo.actionFunc(parserCopy.front.value, /*ref*/ commandInstance); 172 parser.popFront(); // Keep the main parser up to date. 173 174 return result; 175 } 176 177 private Result!void parseDefault(ref ArgPullParser parser, ref CommandT commandInstance, NamedArgumentInfo!CommandT argInfo) 178 { 179 parser.popFront(); 180 181 if(parser.front.type == ArgTokenType.EOF) 182 return typeof(return).failure("defined without a value."); 183 else if(parser.front.type != ArgTokenType.Text) 184 return typeof(return).failure("expected a value, not an argument name."); 185 186 return argInfo.actionFunc(parser.front.value, /*ref*/ commandInstance); 187 } 188 189 private Result!void parseRepeatableName(ref ArgPullParser parser, ref CommandT commandInstance, NamedArgumentInfo!CommandT argInfo) 190 { 191 auto parserCopy = parser; 192 auto incrementBy = 1; 193 194 // Support "-vvvvv" syntax. 195 parserCopy.popFront(); 196 if(parser.front.type == ArgTokenType.ShortHandArgument 197 && parserCopy.front.type == ArgTokenType.Text 198 && parserCopy.front.value.all!(c => c == parser.front.value[0])) 199 { 200 incrementBy += parserCopy.front.value.length; 201 parser.popFront(); // keep main parser up to date. 202 } 203 204 // .actionFunc will perform an increment each call. 205 foreach(i; 0..incrementBy) 206 argInfo.actionFunc(null, /*ref*/ commandInstance); 207 208 return Result!void.success(); 209 } 210 211 private ArgRuntimeInfo!(NamedArgumentInfo!CommandT)[] getNamedArgs() 212 { 213 typeof(return) toReturn; 214 215 foreach(arg; Info.namedArgs) 216 { 217 arg.uda.pattern.assertNoWhitespace(); 218 toReturn ~= this.argInfoOf(arg); 219 } 220 221 // TODO: Forbid arguments that have the same pattern and/or subpatterns. 222 223 return toReturn; 224 } 225 226 private ArgRuntimeInfo!(PositionalArgumentInfo!CommandT)[] getPositionalArgs() 227 { 228 typeof(return) toReturn; 229 230 foreach(arg; Info.positionalArgs) 231 toReturn ~= this.argInfoOf(arg); 232 233 toReturn.sort!"a.argInfo.uda.position < b.argInfo.uda.position"(); 234 foreach(i, arg; toReturn) 235 { 236 assert( 237 arg.argInfo.uda.position == i, 238 "Expected positional argument %s to take up position %s, not %s." 239 .format(toReturn[i].argInfo.uda.name, i, arg.argInfo.uda.position) 240 ); 241 } 242 243 // TODO: Make sure there are no optional args appearing before any mandatory ones. 244 245 return toReturn; 246 } 247 248 private Result!void validateArgs( 249 ArgRuntimeInfo!(NamedArgumentInfo!CommandT)[] namedArgs, 250 ArgRuntimeInfo!(PositionalArgumentInfo!CommandT)[] positionalArgs 251 ) 252 { 253 import std.algorithm : filter, map; 254 import std.format : format; 255 import std.exception : assumeUnique; 256 257 char[] error; 258 error.reserve(512); 259 260 // Check for missing args. 261 auto missingNamedArgs = namedArgs.filter!(a => !a.isNullable && !a.wasFound); 262 auto missingPositionalArgs = positionalArgs.filter!(a => !a.isNullable && !a.wasFound); 263 if(!missingNamedArgs.empty) 264 { 265 foreach(arg; missingNamedArgs) 266 { 267 const name = arg.argInfo.uda.pattern.defaultPattern; 268 error ~= (name.length == 1) ? "-" : "--"; 269 error ~= name; 270 error ~= ", "; 271 } 272 } 273 if(!missingPositionalArgs.empty) 274 { 275 foreach(arg; missingPositionalArgs) 276 { 277 error ~= "<"; 278 error ~= arg.argInfo.uda.name; 279 error ~= ">, "; 280 } 281 } 282 283 if(error.length > 0) 284 { 285 error = error[0..$-2]; // Skip extra ", " 286 return Result!void.failure("missing required arguments " ~ error.assumeUnique); 287 } 288 289 return Result!void.success(); 290 } 291 } 292 293 version(unittest) 294 { 295 // For the most part, these are just some choice selections of tests from core.d that were moved over. 296 297 // NOTE: The only reason it can see and use private @Commands is because they're in the same module. 298 @Command("", "This is a test command") 299 private struct CommandTest 300 { 301 // These are added to test that they are safely ignored. 302 alias al = int; 303 enum e = 2; 304 struct S 305 { 306 } 307 void f () {} 308 309 @CommandNamedArg("a|avar", "A variable") 310 int a; 311 312 @CommandPositionalArg(0, "b") 313 Nullable!string b; 314 315 @CommandNamedArg("c") 316 Nullable!bool c; 317 } 318 @("General test") 319 unittest 320 { 321 auto command = CommandParser!CommandTest(); 322 auto instance = CommandTest(); 323 324 resultAssert(command.parse(["-a 20"], instance)); 325 assert(instance.a == 20); 326 assert(instance.b.isNull); 327 assert(instance.c.isNull); 328 329 instance = CommandTest.init; 330 resultAssert(command.parse(["20", "--avar 20"], instance)); 331 assert(instance.a == 20); 332 assert(instance.b.get == "20"); 333 334 instance = CommandTest.init; 335 resultAssert(command.parse(["-a 20", "-c"], instance)); 336 assert(instance.c.get); 337 } 338 339 @Command("booltest", "Bool test") 340 private struct BoolTestCommand 341 { 342 @CommandNamedArg("a") 343 bool definedNoValue; 344 345 @CommandNamedArg("b") 346 bool definedFalseValue; 347 348 @CommandNamedArg("c") 349 bool definedTrueValue; 350 351 @CommandNamedArg("d") 352 bool definedNoValueWithArg; 353 354 @CommandPositionalArg(0) 355 string comesAfterD; 356 } 357 @("Test that booleans are handled properly") 358 unittest 359 { 360 auto command = CommandParser!BoolTestCommand(); 361 auto instance = BoolTestCommand(); 362 363 resultAssert(command.parse(["-a", "-b=false", "-c", "true", "-d", "Lalafell"], instance)); 364 assert(instance.definedNoValue); 365 assert(!instance.definedFalseValue); 366 assert(instance.definedTrueValue); 367 assert(instance.definedNoValueWithArg); 368 assert(instance.comesAfterD == "Lalafell"); 369 } 370 371 @Command("rawListTest", "Test raw lists") 372 private struct RawListTestCommand 373 { 374 @CommandNamedArg("a") 375 bool dummyThicc; 376 377 @CommandRawListArg 378 string[] rawList; 379 } 380 @("Test that raw lists work") 381 unittest 382 { 383 CommandParser!RawListTestCommand command; 384 RawListTestCommand instance; 385 386 resultAssert(command.parse(["-a", "--", "raw1", "raw2"], instance)); 387 assert(instance.rawList == ["raw1", "raw2"], "%s".format(instance.rawList)); 388 } 389 390 @ArgValidator 391 private struct Expect(T) 392 { 393 T value; 394 395 Result!void onValidate(T boundValue) 396 { 397 import std.format : format; 398 399 return this.value == boundValue 400 ? Result!void.success() 401 : Result!void.failure("Expected value to equal '%s', not '%s'.".format(this.value, boundValue)); 402 } 403 } 404 405 @Command("validationTest", "Test validation") 406 private struct ValidationTestCommand 407 { 408 @CommandPositionalArg(0) 409 @Expect!string("lol") 410 string value; 411 } 412 @("Test ArgBinder validation integration") 413 unittest 414 { 415 CommandParser!ValidationTestCommand command; 416 ValidationTestCommand instance; 417 418 resultAssert(command.parse(["lol"], instance)); 419 assert(instance.value == "lol"); 420 421 assert(!command.parse(["nan"], instance).isSuccess); 422 } 423 424 @Command("arg action count", "Test that the count arg action works") 425 private struct ArgActionCount 426 { 427 @CommandNamedArg("c") 428 @(CommandArgAction.count) 429 int c; 430 } 431 @("Test that CommandArgAction.count works.") 432 unittest 433 { 434 CommandParser!ArgActionCount command; 435 436 void test(string[] args, int expectedCount) 437 { 438 ArgActionCount instance; 439 resultAssert(command.parse(args, instance)); 440 assert(instance.c == expectedCount); 441 } 442 443 ArgActionCount instance; 444 445 test([], 0); 446 test(["-c"], 1); 447 test(["-c", "-c"], 2); 448 test(["-ccccc"], 5); 449 assert(!command.parse(["-ccv"], instance).isSuccess); // -ccv -> [name '-c', positional 'cv']. -1 because too many positional args. 450 test(["-c", "cccc"], 5); // Unfortunately this case also works because of limitations in ArgPullParser 451 } 452 }