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