The OpenD Programming Language

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 }