1 /++ 2 Module for helping to make command line interface programs. 3 4 5 You make an object with methods. Those methods take arguments and it reads them automatically for you. Or, you just make one function. 6 7 ./yourprogram args... 8 9 or 10 11 ./yourprogram class_method_name args.... 12 13 Args go to: 14 bool: --name or --name=true|false 15 string/int/float/enum: --name=arg or --name arg 16 int[]: --name=arg,arg,arg or --name=arg --name=arg that you can repeat 17 string[] : remainder; the name is ignored, these are any args not already consumed by args 18 FilePath and FilePath[]: not yet supported 19 20 `--` always stops populating names and puts the remaining in the final string[] args param (if there is one) 21 `--help` always 22 23 Bugs: 24 no positional arg support at all 25 26 Return values: 27 int is the return value to the cli 28 string is output, returns 0 29 other types are converted to string except for CliResult, which lets you specify output, error, and code in one struct. 30 Exceptions: 31 are printed with fairly minimal info to the stderr, cause program to return 1 unless it has a code attached 32 33 History: 34 Added May 23, 2025 35 +/ 36 module arsd.cli; 37 38 // stdin: 39 40 /++ 41 You can pass a function to [runCli] and it will parse command line arguments 42 into its arguments, then turn its return value (if present) into a cli return. 43 +/ 44 unittest { 45 static // exclude from docs 46 void func(int a, string[] otherArgs) { 47 // because we run the test below with args "--a 5" 48 assert(a == 5); 49 assert(otherArgs.length == 0); 50 } 51 52 int main(string[] args) { 53 // make your main function forward to runCli!your_handler 54 return runCli!func(args); 55 } 56 57 assert(main(["unittest", "--a", "5"]) == 0); 58 } 59 60 /++ 61 You can also pass a class to [runCli], and its public methods will be made 62 available as subcommands. 63 +/ 64 unittest { 65 static // exclude from docs 66 class Thing { 67 void func(int a, string[] args) { 68 assert(a == 5); 69 assert(args.length == 0); 70 } 71 72 // int return values are forwarded to `runCli`'s return value 73 int other(bool flag) { 74 return flag ? 1 : 0; 75 } 76 } 77 78 int main(string[] args) { 79 // make your main function forward to runCli!your_handler 80 return runCli!Thing(args); 81 } 82 83 assert(main(["unittest", "func", "--a", "5"]) == 0); 84 assert(main(["unittest", "other"]) == 0); 85 assert(main(["unittest", "other", "--flag"]) == 1); 86 } 87 88 import arsd.core; 89 90 /++ 91 92 Params: 93 handler = function or class holding the handler 94 handlerCtorArgs = arguments to pass to the constructor of `handler`, if it is a class 95 96 History: 97 handlerCtorArgs were added November 21, 2025 98 +/ 99 int runCli(alias handler, HandlerCtorArgs...)(string[] args, HandlerCtorArgs handlerCtorArgs) { 100 CliHandler thing; 101 102 static if(is(handler == class)) { 103 CliHandler[] allOptions; 104 105 scope auto instance = new handler(handlerCtorArgs); 106 foreach(memberName; __traits(derivedMembers, handler)) { 107 static if(memberName != "__ctor" && memberName != "__dtor") { 108 alias member = __traits(getMember, handler, memberName); 109 static if(__traits(getProtection, member) == "public") { 110 static if(is(typeof(member) == return)) { 111 auto ourthing = createCliHandler!member(); 112 if(args.length > 1 && ourthing.uda.name == args[1]) { 113 thing = ourthing; 114 break; 115 } 116 allOptions ~= ourthing; 117 } 118 } 119 } 120 } 121 122 if(args.length && args[1] == "--help") { 123 foreach(option; allOptions) 124 writeln(option.printHelp()); 125 126 return 0; 127 } 128 129 if(args.length) 130 args = args[1 .. $]; // cut off the original args(0) as irrelevant now, the command is the new args[0] 131 } else { 132 static assert(HandlerCtorArgs.length == 0, "can only pass ctor args to a class handler"); 133 auto instance = null; 134 thing = createCliHandler!handler(); 135 } 136 137 if(!thing.uda.unprocessed && args.length > 1 && args[1] == "--help") { 138 writeln(thing.printHelp()); 139 return 0; 140 } 141 142 if(thing.handler is null) { 143 throw new CliArgumentException("subcommand", "no handler found"); 144 } 145 146 auto ret = thing.handler(thing, instance, args); 147 if(ret.output.length) 148 writeln(ret.output); 149 if(ret.error.length) 150 writelnStderr(ret.error); 151 return ret.returnValue; 152 } 153 154 /++ 155 Allows you to construct a class from CLI arguments, if and only if it has exactly one constructor. 156 157 It will print error messages to stderr and, if requested, help to stdout, giving you a return value in the `ret` argument. If this returns `null`, you should forward `ret` to `main` (usually). If the return value is not `null`, you should not use `ret`. 158 159 The purpose of this is to use cli stuff as a startup helper, but then proceed with the program using the class object. You may add `@Cli` udas to your constructor arguments. 160 161 History: 162 Added February 1, 2026 163 +/ 164 Class constructFromCliArgs(Class)(string[] args, out int ret) { 165 assert(args.length > 0); 166 167 Class c; 168 169 static class Helper { 170 this(Class* c) { 171 this.c = c; 172 } 173 Class* c; 174 175 static if(is(typeof(__traits(getMember, Class, "__ctor")) Params == __parameters)) 176 int factory(Params p) { 177 *c = new Class(p); 178 return 100; 179 } 180 else static assert(0, "Class did not have a constructor"); 181 } 182 183 ret = runCli!Helper([args[0], "factory"] ~ args[1..$], &c); 184 if(ret != 100) 185 return null; 186 187 return c; 188 } 189 190 /// 191 unittest { 192 import arsd.cli; 193 194 int main(string[] args) { 195 static class A { 196 this(@Cli(required: true) int a) { 197 assert(a == 4); 198 } 199 } 200 int ret; 201 A a = constructFromCliArgs!A([null, "--a=4"], ret); 202 assert(a !is null); // for this test, the construction must succeed 203 204 // but if it didn't, we should return the error from `main` 205 if(a is null) 206 return ret; 207 208 // can now use `a` here 209 210 return 0; 211 } 212 213 // the `null` here is the program name, args[0] by tradition, then args for the constructor, so the param for `int a` 214 assert(main([null, "--a=4"]) == 0); 215 } 216 217 /++ 218 219 +/ 220 class CliArgumentException : object.Exception { 221 this(string argument, string message) { 222 super(argument ~ ": " ~ message); 223 } 224 } 225 226 /++ 227 If your function returns `CliResult`, you can return a value and some output in one object. 228 229 Note that output and error are written to stdout and stderr, in addition to whatever the function 230 did inside. It does NOT represent captured stuff, it is just a function return value. 231 +/ 232 struct CliResult { 233 int returnValue; 234 string output; 235 string error; 236 } 237 238 /++ 239 Can be attached as a UDA to override defaults 240 +/ 241 struct Cli { 242 string name; 243 244 string summary; 245 string help; 246 247 // only valid on function - passes the original args without processing them at all, not even --help 248 bool unprocessed; // FIXME mostly not implemented 249 // only valid on function - instead of erroring on unknown arg, just pass them unmodified to the catch-all array 250 bool passthroughUnrecognizedArguments; // FIXME not implemented 251 252 253 // only valid on arguments 254 dchar shortName; // bool things can be combined and if it is int it can take one like -O2. maybe. 255 int required = 2; 256 int arg0 = 2; 257 int consumesRemainder = 2; 258 int holdsAllArgs = 2; // FIXME: not implemented 259 string[] options; // FIXME if it is not one of the options and there are options, should it error? 260 } 261 262 263 version(sample) 264 void handler(bool sweetness, @Cli(arg0: true) string programName, float f, @Cli(required: true) int a, @Cli(name: "opend-to-build") string[] magic, int[] foo, string[] remainder) { 265 import arsd.core; 266 267 if(a == 4) 268 throw ArsdException!"lol"(4, 6); 269 270 mixin(dumpParams); 271 debug dump(__traits(parameters)); 272 debug dump(i"$programName"); 273 274 static struct Test { 275 int a; 276 string b; 277 float c; 278 } 279 280 debug dump(Test(a: 5, b: "omg", c: 7.5)); 281 } 282 283 version(sample) 284 int main(string[] args) { 285 /+ 286 import arsd.core; 287 auto e = extractCliArgs(args, false, ["a":true]); 288 foreach(a; e) 289 writeln(a.name, a.values); 290 return 0; 291 +/ 292 293 return runCli!handler(args); 294 } 295 296 private enum SupportedCliTypes { 297 String, 298 Int, 299 Float, 300 Bool, 301 IntArray, 302 StringArray 303 } 304 305 private struct CliArg { 306 Cli uda; 307 string argumentName; 308 string ddoc; 309 SupportedCliTypes type; 310 //string default; 311 } 312 313 private struct CliHandler { 314 CliResult function(CliHandler info, Object _this, string[] args) handler; 315 Cli uda; 316 CliArg[] args; 317 318 string methodName; 319 string ddoc; 320 321 string printHelp() { 322 string help = uda.name; 323 if(help.length) 324 help ~= ": "; 325 help ~= uda.help; 326 foreach(arg; args) { 327 if(!arg.uda.required) 328 help ~= "["; 329 if(arg.uda.consumesRemainder) 330 help ~= "args..."; 331 else if(arg.type == SupportedCliTypes.Bool) 332 help ~= "--" ~ arg.uda.name; 333 else 334 help ~= "--" ~ arg.uda.name ~ "=" ~ enumNameForValue(arg.type); 335 if(!arg.uda.required) 336 help ~= "]"; 337 help ~= " "; 338 } 339 340 // FIXME: print the help details for the args 341 342 return help; 343 } 344 } 345 346 private template CliTypeForD(T) { 347 static if(is(T == enum)) 348 enum CliTypeForD = SupportedCliTypes.String; 349 else static if(is(T == string)) 350 enum CliTypeForD = SupportedCliTypes.String; 351 else static if(is(T == bool)) 352 enum CliTypeForD = SupportedCliTypes.Bool; 353 else static if(is(T : long)) 354 enum CliTypeForD = SupportedCliTypes.Int; 355 else static if(is(T : double)) 356 enum CliTypeForD = SupportedCliTypes.Float; 357 else static if(is(T : int[])) 358 enum CliTypeForD = SupportedCliTypes.IntArray; 359 else static if(is(T : string[])) 360 enum CliTypeForD = SupportedCliTypes.StringArray; 361 else 362 static assert(0, "Unsupported type for CLI: " ~ T.stringof); 363 } 364 365 private CliHandler createCliHandler(alias handler)() { 366 CliHandler ret; 367 368 ret.methodName = __traits(identifier, handler); 369 version(D_OpenD) 370 ret.ddoc = __traits(docComment, handler); 371 372 foreach(uda; __traits(getAttributes, handler)) 373 static if(is(typeof(uda) == Cli)) 374 ret.uda = uda; 375 376 if(ret.uda.name is null) 377 ret.uda.name = ret.methodName; 378 if(ret.uda.help is null) 379 ret.uda.help = ret.ddoc; 380 if(ret.uda.summary is null) 381 ret.uda.summary = ret.uda.help; // FIXME: abbreviate 382 383 static if(is(typeof(handler) Params == __parameters)) 384 foreach(idx, param; Params) { 385 CliArg arg; 386 387 arg.argumentName = __traits(identifier, Params[idx .. idx + 1]); 388 // version(D_OpenD) arg.ddoc = __traits(docComment, Params[idx .. idx + 1]); 389 390 arg.type = CliTypeForD!param; 391 392 foreach(uda; __traits(getAttributes, Params[idx .. idx + 1])) 393 static if(is(typeof(uda) == Cli)) { 394 arg.uda = uda; 395 // import std.stdio; writeln(cast(int) uda.arg0); 396 } 397 398 399 // if not specified by user, replace with actual defaults 400 if(arg.uda.consumesRemainder == 2) { 401 if(idx + 1 == Params.length && is(param == string[])) 402 arg.uda.consumesRemainder = true; 403 else 404 arg.uda.consumesRemainder = false; 405 } else { 406 assert(0, "do not set consumesRemainder explicitly at least not at this time"); 407 } 408 if(arg.uda.arg0 == 2) 409 arg.uda.arg0 = false; 410 if(arg.uda.required == 2) 411 arg.uda.required = false; 412 if(arg.uda.holdsAllArgs == 2) 413 arg.uda.holdsAllArgs = false; 414 static if(is(param == enum)) 415 if(arg.uda.options is null) 416 arg.uda.options = [__traits(allMembers, param)]; 417 418 if(arg.uda.name is null) 419 arg.uda.name = arg.argumentName; 420 421 ret.args ~= arg; 422 } 423 424 ret.handler = &cliForwarder!handler; 425 426 return ret; 427 } 428 429 private struct ExtractedCliArgs { 430 string name; 431 string[] values; 432 } 433 434 private ExtractedCliArgs[] extractCliArgs(string[] args, bool needsCommandName, bool[string] namesThatTakeSeparateArguments) { 435 // FIXME: if needsCommandName, args[1] should be that 436 ExtractedCliArgs[] ret; 437 if(args.length == 0) 438 return [ExtractedCliArgs(), ExtractedCliArgs()]; 439 440 ExtractedCliArgs remainder; 441 442 ret ~= ExtractedCliArgs(null, [args[0]]); // arg0 is a bit special, always the first one 443 args = args[1 .. $]; 444 445 ref ExtractedCliArgs byName(string name) { 446 // FIXME: could actually do a map to index thing if i had to 447 foreach(ref r; ret) 448 if(r.name == name) 449 return r; 450 ret ~= ExtractedCliArgs(name); 451 return ret[$-1]; 452 } 453 454 string nextArgName = null; 455 456 void appendPossibleEmptyArg() { 457 if(nextArgName is null) 458 return; 459 byName(nextArgName).values ~= null; 460 nextArgName = null; 461 } 462 463 foreach(idx, arg; args) { 464 if(arg is null) 465 continue; 466 467 if(arg == "--") { 468 remainder.values ~= args[idx + 1 .. $]; 469 break; 470 } 471 472 if(arg[0] == '-') { 473 // short name or short nameINT_VALUE 474 // -longname or -longname=VALUE. if -longname, next arg is its value unless next arg starts with -. 475 476 if(arg.length == 1) { 477 // plain - often represents stdin or whatever, treat it as a normal filename arg 478 remainder.values ~= arg; 479 } else { 480 appendPossibleEmptyArg(); 481 482 string value; 483 if(arg[1] == '-') { 484 // long name... 485 import arsd.string; 486 auto equal = arg.indexOf("="); 487 if(equal != -1) { 488 nextArgName = arg[2 .. equal]; 489 value = arg[equal + 1 .. $]; 490 } else { 491 nextArgName = arg[2 .. $]; 492 } 493 } else { 494 // short name 495 nextArgName = arg[1 .. $]; // FIXME what if there's bundled? or an arg? 496 } 497 byName(nextArgName); 498 if(value !is null) { 499 byName(nextArgName).values ~= value; 500 nextArgName = null; 501 } else if(!namesThatTakeSeparateArguments.get(nextArgName, false)) { 502 byName(nextArgName).values ~= null; // just so you can see how many times it appeared 503 nextArgName = null; 504 } 505 } 506 } else { 507 if(nextArgName !is null) { 508 byName(nextArgName).values ~= arg; 509 510 nextArgName = null; 511 } else { 512 remainder.values ~= arg; 513 } 514 } 515 } 516 517 appendPossibleEmptyArg(); 518 519 ret ~= remainder; // remainder also a bit special, always the last one 520 521 return ret; 522 } 523 524 // FIXME: extractPrefix for stuff like --opend-to-build and --DRT- stuff 525 526 private T extractCliArgsT(T)(CliArg info, ExtractedCliArgs[] args) { 527 try { 528 import arsd.conv; 529 if(info.uda.arg0) { 530 static if(is(T == string)) { 531 return args[0].values[0]; 532 } else { 533 assert(0, "arg0 consumers must be type string"); 534 } 535 } 536 537 if(info.uda.consumesRemainder) 538 static if(is(T == string[])) { 539 return args[$-1].values; 540 } else { 541 assert(0, "remainder consumers must be type string[]"); 542 } 543 544 foreach(arg; args) 545 if(arg.name == info.uda.name) { 546 static if(is(T == string[])) 547 return arg.values; 548 else static if(is(T == int[])) { 549 int[] ret; 550 ret.length = arg.values.length; 551 foreach(i, a; arg.values) 552 ret[i] = to!int(a); 553 554 return ret; 555 } else static if(is(T == bool)) { 556 // if the argument is present, that means it is set unless the value false was explicitly given 557 if(arg.values.length) 558 return arg.values[$-1] != "false"; 559 return true; 560 } else { 561 if(arg.values.length == 1) 562 return to!T(arg.values[$-1]); 563 else 564 throw ArsdException!"wrong number of args"(arg.values.length); 565 } 566 } 567 568 return T.init; 569 } catch(Exception e) { 570 throw new CliArgumentException(info.uda.name, e.toString); 571 } 572 } 573 574 private CliResult cliForwarder(alias handler)(CliHandler info, Object this_, string[] args) { 575 try { 576 static if(is(typeof(handler) Params == __parameters)) 577 Params params; 578 579 assert(Params.length == info.args.length); 580 581 bool[string] map; 582 foreach(a; info.args) 583 if(a.type != SupportedCliTypes.Bool) 584 map[a.uda.name] = true; 585 auto eargs = extractCliArgs(args, false, map); 586 587 /+ 588 import arsd.core; 589 foreach(a; eargs) 590 writeln(a.name, a.values); 591 +/ 592 593 foreach(a; eargs[1 .. $-1]) { 594 bool found; 595 foreach(a2; info.args) 596 if(a.name == a2.uda.name) { 597 found = true; 598 break; 599 } 600 if(!found) 601 throw new CliArgumentException(a.name, "Invalid arg"); 602 } 603 604 // FIXME: look for missing required argument 605 foreach(a; info.args) { 606 if(a.uda.required) { 607 bool found = false; 608 foreach(a2; eargs[1 .. $-1]) { 609 if(a2.name == a.uda.name) { 610 found = true; 611 break; 612 } 613 } 614 if(!found) 615 throw new CliArgumentException(a.uda.name, "Missing required arg"); 616 } 617 } 618 619 foreach(idx, ref param; params) { 620 param = extractCliArgsT!(typeof(param))(info.args[idx], eargs); 621 } 622 623 auto callit() { 624 static if(is(__traits(parent, handler) Parent == class)) { 625 auto instance = cast(Parent) this_; 626 assert(instance !is null); 627 return __traits(child, instance, handler)(params); 628 } else { 629 return handler(params); 630 } 631 } 632 633 static if(is(typeof(handler) Return == return)) { 634 static if(is(Return == void)) { 635 callit(); 636 return CliResult(0); 637 } else static if(is(Return == int)) { 638 return CliResult(callit()); 639 } else static if(is(Return == string)) { 640 return CliResult(0, callit()); 641 } else static assert(0, "Invalid return type on handler: " ~ Return.stringof); 642 } else static assert(0, "bad handler"); 643 } catch(CliArgumentException e) { 644 auto str = e.msg; 645 auto idx = str.indexOf("------"); 646 if(idx != -1) 647 str = str[0 .. idx]; 648 str = str.stripInternal(); 649 return CliResult(1, null, str); 650 } catch(Throwable t) { 651 auto str = t.toString; 652 auto idx = str.indexOf("------"); 653 if(idx != -1) 654 str = str[0 .. idx]; 655 str = str.stripInternal(); 656 return CliResult(1, null, str); 657 } 658 }