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 }