~yosh@unix.dog

getopt() but friendlier

posted

I’ve been thinking a lot about “making C generally nicer to use” recently. The language has been stuck in The Dark Ages in terms of both standard behavior and programmer practice for a while, and I feel like only recently—with the works of people like ThePhD on the C standard or Wellons blogging about his C practices—has some amount of momentum and public discussion been put on improving the ergonomics of C internally and externally.1

I feel like there’s a happy middle ground to be found between the average 90s C codebase and the Greenspun’s lament of projects like metalang99.2 Of course, this middle ground is already found in languages like Zig or Hare, but those benefit from being new and unencumbered by backwards compatibility right now.3 I want to know what can be done to make ragtag old C nicer to use.

I came across fanf’s article on option parsing recently, and a bored enough me paid enough attention to think about the problem space myself during the night. I’m not sure how much use this might have for me, as most of the time I interact with C is contributing to other projects, but this exists now, and I don’t really want to make it un-exist, so it’s here now. Gives me an excuse to ramble.

what makes nice CLI argument handling?

For me, compared to getopt(), not much, really. The biggest divergence that my opinion has from POSIX is that I’d like to not have to specify flags before positional arguments, i.e. ./prog -a file should be the same as ./prog file -a. Arguments would of course be treated as positional after a --, and a lone - should be treated as positional since it’s a common idiom for stdin/stdout. This seems to be the common consensus among Modern Terminal Programs™, so it’s nice to get up to speed.

It’s also nice when short and long option arguments have a single consistent separator. This is mainly something I noticed with GNU tools, as they accept either = or the next *argv as the long option argument. Not like, the end of the world, but it just feels like unnecessary leniency. It also means I don’t have to use a form of string slices to separate an option from its argument, which keeps things slightly simpler.4 There’s also a common practice for short options to allow no separator, i.e. -Wall -Wextra -Wpedantic. I’m not personally a fan of this, since with sufficiently many options it might conflate flag chaining with an argument, but others seem to like it, and it made the API for what I wrote a bit more consistent, so I decided to keep it.

what makes a nice argument parser?

If the language is ripe for it, I find a mostly-declarative argument parser to be quite nice. They can coalesce long opts, short opts, help text, whatever in one neat bundle. I was actually initially wanting to make one of these for C a few months ago. Alas, C is not a language ripe for declarative-style compositions, and any time I worked my brain on the macro hell that’d emerge from such a library, I’d shunt the project idea to the archives for a few more weeks.

fanf’s article (and the rust crate it derives from) opted for an iterator-esque immediate parser. Duh! It’s the pretty obvious approach for an imperative language. Not sure why I didn’t view it from that angle before. If you ask me, the Rust crate is close to the ideal form of this style of argument parsing, with a loop that iterates over argv and matches against argument types, leaving the rest of the logic to the programmer. Without pattern matching, it means using some ugly if blocks, but that’s the cost of C.

implementation

I’m personally a fan of modeling iterators in C after C#/Rust iterators as opposed to C++ iterators. Mainly since the latter’s use in a for loop doesn’t translate to C too nicely, requiring bundling the value in the iterator state. In any case, iterators require keeping some kind of state somewhere for knowing where you are. fanf uses global variables for this at the cost of not being re-entrant, but in line with the “object-y” nature of iterators in general, I find it more idiomatic to shunt state to a struct that gets passed to the next() function:

typedef struct {
	bool posonly;
	int short_idx;
	char **vec;
} ArgCtx;

static ArgCtx
arg_init(char **argv) {
	return (ArgCtx) {
		.posonly = false,
		.short_idx = 0,
		.vec = argv[0] == NULL ? argv : argv + 1,
	};
}

static Arg
arg_next(ArgCtx *ctx) {
	// ...
}

The iterator keeps extra state for whether the end of options mark was found (posonly) and the current index in a chain of short options (short_idx). The implementation of arg_next() is roughly similar to that of fnaf’s, just that I use a tagged union for arguments:

typedef struct {
	ArgType type;
	union {
		char chr;
		char *str;
	};
} Arg;

If the argument is short, look at .chr. Otherwise, look at .str. There’s some macros for helping achieve the desired loop feel, and there’s an arg_err() function that works just like fanf’s:

#include "arg.h"

static char *USAGE_STR =
	"./a.out [-a] [-s/--string STR] [-t/--test] ARG..."
	;

int
main(int argc, char **argv) {
	if (argc <= 1) {
		printf(stdout, "usage: %s\n", USAGE_STR);
		return 0;
	}
	ArgCtx argctx = arg_init(argv);
	Arg arg;
	while (!ARG_DONE(arg = arg_next(&argctx))) {
		if (ARG_SHORT(arg, 'a')) {
			printf("Option -a passed\n");
		} else if (ARG_SHORT(arg, 's') || ARG_LONG(arg, "string")) {
			char *s = arg_valnext(&argctx);
			if (s == NULL) {
				arg_err(arg, "requires argument.", USAGE_STR);
			}
			printf("Option -s/--string is: %s\n", s);
		} else if (ARG_LONG(arg, "test") || ARG_SHORT(arg, 't')) {
			printf("Option --test or -t passed!\n");
		} else if (ARG_POS(arg)) {
			printf("Positional argument: %s\n", arg.str);
		} else {
			arg_err(arg, "unknown option.", USAGE_STR);
		}
	}
}

conclusion

There’s a lot of things you tend to learn about once, use as-is, then get comfortable enough to not think about it at a deeper level. getopt() was one of those for me, since for the longest time when I was writing C, I wanted to “stick to established conventions and standards”. It’s neat to look at wheels again every once in a while, as I’ve recently been doing with this half a century old language.

I don’t really write C much nowadays, though I find it tends to be a comfort language for me, almost? Even though I am aware of its unergonomic design, find other languages nicer to write, and don’t have much use for it in day-to-day programming, I still just find myself coming back to it from time to time. I’m not sure why. I talked with some friends about it, and they concurred a similar “comfort language” feeling with JavaScript—a language also known to be unergonomic and footgun-y. Curious!

Anyway, that’s really all. Not sure how much use I’ll get out of this, but for any others who happen to stop by, you can find the full code as a single-header file in this fancy new repo I made for snippets. It’s C23 since it uses bool as a keyword, but it can easily be made C99 by adding an #include <stdbool.h> before it. Your call.


  1. I’ve been realizing from using many other languages that most of the “footguns” C has can just be attributed to its very poor ergonomics or legacy standard practice. It’s an uphill battle to use an allocator interface that helps organize lifetimes as opposed to just malloc()ing and free()ing everything. It’s an uphill battle to use data + len strings instead of NUL-terminated ones. Most of the uphill battles are due to interacting with old libraries. There’s more reasons, of course, but expanding upon them is certainly out of the scope of this post. ↩︎

  2. Not to say that ML99 is a bad project—far from it! I used datatype99 and interface99 for prototyping a little ffmpeg tool that couldn’t be done with the command line. It was quite fun! And I can see its use! But without pre-compiled headers and other chicanery, compile times were a little meh, I found I wasn’t using most of its true power, and clangd didn’t like some of it. ↩︎

  3. I hesitate to include Rust in this list, since it’s a more ML-influenced language with fundamentally different semantics compared to zig or hare. I do greatly enjoy using it, though. ↩︎

  4. Yes, I am a fan of using data+len strings/slices just about everywhere in C, but introducing them when handling argc/argv just felt a bit over-the-top. Keeping the design simple by just managing the strings as-is seemed more preferable, especially since you only really have one minor concession without slices. ↩︎

back