Command-Line Programs in C
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
C is the language the command-line world is built on: the shell, ls, grep, git and the compilers are C programs,
and the interface they all share — an argument vector, three standard streams, an environment, an exit status and
a handful of signals — is the interface of C’s main. This page builds one complete C program, the Bookshelf
shelf tool, and uses it to show every piece a real command-line program needs: parsing options, reading input
until end of file, reporting errors with the right exit status, reading the environment, cleaning up when the user
presses Ctrl+C, reading single keys in raw mode, and doing the same things on Windows, where the C runtime differs.
The page was written against glibc 2.44 (Linux), the macOS libc, the Microsoft C runtime (UCRT) and POSIX.1-2024. The shelf program, the error and input snippets and the raw-mode key reader were
compiled with Apple clang 21 on macOS (with -Wall -Wextra -Wpedantic, and once with the address and undefined-behaviour
sanitizers) and run there, including the key reader under a pseudo-terminal. These parts were not run while the page
was written: everything that needs glibc (argp, secure_getenv, behaviour listed as "glibc" in the tables), the
Windows sources (wmain, _setmode) and the CMake and Meson files, because no Linux, Windows, CMake or Meson
installation was available. Each such snippet says so where it appears.
Several topics have their own pages and are linked rather than repeated. The process model (fork, exec, signals in
depth, SIGPIPE) is on POSIX Processes, Signals & I/O; the
terminal line discipline and termios theory on Terminals, TTYs &
Pseudo-Terminals; the escape sequences used to draw on screen on
Escape Sequences & Terminal Capabilities; the Windows
console, code pages and argument splitting on Windows Console &
Processes; and the language itself on the C Reference, in particular
Program Structure,
Input, Output and Files and
Build and Tooling. How a command-line tool should behave (stream
conventions, exit-code taxonomy, colour rules) is on Streams & Exit
Codes and TTY, Colour & Interactivity; this page shows how
C implements those rules.
The shelf program at a glance
shelf keeps a catalogue of books in a tab-separated file and manages it with subcommands.
| Command | What it does |
|---|---|
|
Prints the catalogue as a table; |
|
Appends a book. |
|
Adds one book per input line ( |
|
Lends a book to someone, or takes it back. |
|
Shows the catalogue and lets the user choose a book with the arrow keys; prints the chosen id on standard output. |
The global options are -f/--file, -v/--verbose, --color=auto|always|never, -h/--help and
-V/--version. The catalogue file is $SHELF_HOME/books.tsv, or books.tsv in the current directory when
SHELF_HOME is not set. The flow of the program, and the exit status of every way out of it, is:
main and the exit status
A C program starts at main. A hosted implementation (a program running under an operating system, as every program
on this page does) accepts the two forms that the C standard requires, and the platforms add a third.
| Signature | Meaning | Portability |
|---|---|---|
|
The program ignores its arguments. |
C standard. |
|
|
C standard; the form to use. |
|
Adds the environment as a null-terminated array of |
Common extension on UNIX and Windows; portable code reads |
|
The Microsoft entry point with UTF-16 arguments, selected automatically by the linker when the program defines it. |
Windows (MSVC; MinGW-w64 needs the |
The arguments are bytes, not text. On Linux they are whatever bytes the caller passed, usually UTF-8 but not
guaranteed to be; on macOS they are normally UTF-8; on Windows the narrow main receives them converted to the
active ANSI code page, which loses characters outside it (see Windows: wmain, UTF-8 and the missing pieces).
The value that main returns is the program’s exit status. Returning from main is the same as calling exit with
that value, and a main that reaches its closing brace without a return returns 0 (a C99 rule that applies to
main only).
| Way out | Behaviour |
|---|---|
|
Runs the functions registered with |
|
Ends the process immediately: no |
|
Raises |
Death by signal |
There is no exit status; the parent sees "killed by signal N", which a shell reports as |
Only EXIT_SUCCESS (0) and EXIT_FAILURE are portable names for the status in standard C. POSIX defines that a parent
receives the low eight bits, so a status above 255 is truncated (exit(256) looks like success). Shells add their own
conventions on top: 126 (found but not executable), 127 (not found) and 128 + N (killed by signal N). Windows
uses a 32-bit exit code, but a program that wants to be scripted from both worlds stays within 0 to 125. The
exit-code taxonomy for tools that scripts and agents call is on
Streams & Exit Codes; how main, exit and the status work in the
language is on Program Structure.
shelf returns one of a small set of named statuses (see Reporting errors) and, before it returns, checks that its output
really reached the destination. A program that prints with printf and ignores the result reports success even when the
disk is full or the pipe is closed. The last statements of the main function of shelf are:
if (fflush(stdout) != 0 || ferror(stdout)) { /* `shelf list > /dev/full` */
if (errno != EPIPE) { /* a closed pipe is the reader's choice */
fprintf(stderr, "%s: write error: %s\n", prog, strerror(errno));
}
return EX_IOERR;
}
return EXIT_SUCCESS;
On Linux, /dev/full is a device whose writes always fail with ENOSPC, which makes the check testable
(shelf list > /dev/full exits with 74 and a "No space left on device" message); macOS has no /dev/full, so that
exact test was not run. A closed pipe was tested on macOS: shelf list | head -n 1 leaves shelf with
EPIPE and exit status 74 but, deliberately, no message.
Source: POSIX — exit and The GNU C Library — Exit Status.
Parsing options
Every command-line tool needs the same decisions: which spellings are accepted (-v, --verbose, -vf FILE,
--file=FILE), whether options may follow operands, what happens with --, and what the program does about an
unknown option. C offers three standard-library-level answers and, on Windows, none of them. The rules a tool should
follow are the POSIX Utility Syntax Guidelines, which
the
POSIX page turns into an implementation checklist, and the GNU Coding Standards for long options, --help and
--version.
getopt |
getopt_long |
argp |
|
|---|---|---|---|
Defined by |
POSIX.1-2024 ( |
GNU extension, copied by the BSDs and macOS ( |
GNU extension of glibc ( |
Short options |
Yes |
Yes |
Yes |
Long options, |
No |
Yes, with unambiguous abbreviations |
Yes, with unambiguous abbreviations |
Generates |
No |
No |
Yes, including formatted option tables and the bug-report address |
Available on |
Linux, macOS, BSDs; not in the MSVC runtime |
Linux, macOS, BSDs, MinGW-w64, Cygwin and MSYS2; not in the MSVC runtime |
glibc only; not in the macOS libc, not in musl, not in the MSVC runtime |
getopt
POSIX getopt takes the option string (for example qvo:: letters, a colon after an option that takes an argument) and returns
one option character per call, setting optarg to the argument, optind to the index of the next argv element and
optopt to the offending character on an error. It stops at the first operand (POSIX behaviour) or at --. glibc
permutes argv so that options after operands are still found, unless the option string starts with + or the
environment variable POSIXLY_CORRECT is set. A complete getopt loop, and the guideline-by-guideline checklist it
implements, are on POSIX Processes, Signals & I/O. It handles short
options only, which is why most tools use getopt_long.
getopt_long
getopt_long adds a table of long options. Each struct option entry has a name, an argument kind
(no_argument, required_argument or optional_argument), a flag pointer and a val. When flag is null the
function returns val, which is usually the short option’s character or, for a long-only option, a number above 255; when
flag points to an int the function stores val there and returns 0.
| Element | Behaviour |
|---|---|
Option string, first character |
Stop at the first operand, as POSIX does. Needed for |
Option string, first character |
Return every operand as if it were the argument of an option with the character code 1 (glibc). |
Option string, |
Return |
|
|
|
The argument must be attached ( |
Abbreviations |
|
|
Globals shared with |
Unknown long option |
Returns |
|
Ends the options; |
The messages the library prints for an unknown option differ between C libraries (macOS prints
unrecognized option ... with a back-quote and an apostrophe; other libraries use two apostrophes), so a program that
needs stable messages, such as shelf, uses the leading : and writes its own. The main loop of shelf:
int main(int argc, char *argv[])
{
const char *slash = strrchr(argv[0], '/');
prog = slash ? slash + 1 : argv[0];
enum { OPT_COLOR = 256 };
static const struct option long_opts[] = {
{ "file", required_argument, NULL, 'f' },
{ "verbose", no_argument, NULL, 'v' },
{ "color", required_argument, NULL, OPT_COLOR },
{ "help", no_argument, NULL, 'h' },
{ "version", no_argument, NULL, 'V' },
{ 0 }
};
struct options o = { 0, 0, NULL };
const char *color_when = "auto";
int ch;
while ((ch = getopt_long(argc, argv, "+:f:vhV", long_opts, NULL)) != -1) {
switch (ch) {
case 'f': o.db = optarg; break;
case 'v': o.verbose = 1; break;
case OPT_COLOR: color_when = optarg; break;
case 'h': usage(stdout); return EXIT_SUCCESS;
case 'V': printf("%s %s\n", prog, VERSION); return EXIT_SUCCESS;
case ':': die(EX_USAGE, "option '%s' needs an argument", argv[optind - 1]);
default: die(EX_USAGE, "unknown option '%s' (try --help)", argv[optind - 1]);
}
}
argc -= optind;
argv += optind;
if (argc == 0) { usage(stderr); return EX_USAGE; }
o.color = want_color(color_when);
char path[PATH_MAX];
if (o.db == NULL) {
const char *home = env_get("SHELF_HOME");
int n = snprintf(path, sizeof path, "%s%sbooks.tsv", home ? home : "", home ? "/" : "");
if (n < 0 || (size_t)n >= sizeof path) die(EX_USAGE, "SHELF_HOME is too long");
o.db = path;
}
install_handlers();
struct catalogue c = { 0 };
load(&c, o.db);
const char *cmd = argv[0];
if (strcmp(cmd, "list") == 0) cmd_list(&o, argc, argv, &c);
else if (strcmp(cmd, "add") == 0) cmd_add(&o, argc, argv, &c);
else if (strcmp(cmd, "import") == 0) cmd_import(&o, argc, argv, &c);
else if (strcmp(cmd, "loan") == 0) cmd_loan(&o, argc, argv, &c);
else if (strcmp(cmd, "return") == 0) cmd_return(&o, argc, argv, &c);
else if (strcmp(cmd, "pick") == 0) cmd_pick(&o, argc, argv, &c);
else die(EX_USAGE, "unknown command '%s' (try --help)", cmd);
if (fflush(stdout) != 0 || ferror(stdout)) { /* `shelf list > /dev/full` */
if (errno != EPIPE) { /* a closed pipe is the reader's choice */
fprintf(stderr, "%s: write error: %s\n", prog, strerror(errno));
}
return EX_IOERR;
}
return EXIT_SUCCESS;
}
A tool with subcommands runs getopt_long again for each subcommand’s own options. The library keeps its position in
global variables, so the second parse has to reset them, and the way to do that differs:
| Platform | Reset before the next parse |
|---|---|
glibc (Linux) |
|
macOS, FreeBSD, OpenBSD, NetBSD |
|
Windows with MinGW-w64, Cygwin, MSYS2 |
The behaviour of the library that ships with the toolchain; test it. |
static void reset_getopt(void)
{
#if defined(__APPLE__) || defined(__FreeBSD__) || defined(__OpenBSD__) || defined(__NetBSD__)
optreset = 1;
optind = 1;
#else
optind = 0; /* glibc: 0 also re-initialises internal state */
#endif
}
static void cmd_list(const struct options *o, int argc, char **argv, struct catalogue *c)
{
static const struct option lo[] = { { "loaned", no_argument, NULL, 'l' }, { 0 } };
int only_loaned = 0, ch;
reset_getopt();
while ((ch = getopt_long(argc, argv, "+:l", lo, NULL)) != -1) {
if (ch == 'l') only_loaned = 1;
else die(EX_USAGE, "list: unknown option '%s'", argv[optind - 1]);
}
if (optind != argc) die(EX_USAGE, "list: unexpected argument '%s'", argv[optind]);
printf(o->color ? "\x1b[1m%4s %-32s %-20s %s\x1b[0m\n" : "%4s %-32s %-20s %s\n",
"ID", "TITLE", "AUTHOR", "LOANED TO");
for (size_t i = 0; i < c->n; i++) {
const struct book *b = &c->v[i];
if (only_loaned && *b->loaned_to == '\0') continue;
printf("%4ld %-32s %-20s %s\n", b->id, b->title, b->author, b->loaned_to);
}
}
$ shelf list --loaned
ID TITLE AUTHOR LOANED TO
2 Emma Jane Austen Ana
$ shelf list -x
shelf: list: unknown option '-x'
$ shelf --verb --file=books.tsv list # unambiguous abbreviation, long option with attached argument
ID TITLE AUTHOR LOANED TO
1 Dune Frank Herbert
$ shelf -vf books.tsv list # grouped short options, the last one takes the argument
ID TITLE AUTHOR LOANED TO
1 Dune Frank Herbert
$ shelf list -- -l # after --, -l is an operand
shelf: list: unexpected argument '-l'
$ echo $?
64
Source: The GNU C Library — Parsing Long Options with getopt_long
and getopt(3) for the option-string prefixes, POSIXLY_CORRECT and the
reset rule. Compiled and run on macOS with the BSD optreset branch; the optind = 0 branch was not run.
Numbers and other typed arguments
atoi cannot report an error. strtol can, if you ask it to: clear errno first, check that it consumed the whole
string, and check ERANGE. shelf rejects abc, 12x, an empty string, an out-of-range number and anything below 1
with one status, 64:
static long parse_id(const char *s)
{
char *end;
errno = 0;
long v = strtol(s, &end, 10);
if (end == s || *end != '\0' || errno == ERANGE || v < 1) {
die(EX_USAGE, "invalid book id '%s'", s);
}
return v;
}
Source: POSIX — strtol.
argp
argp (glibc, <argp.h>) is a declarative alternative: the program describes its options in a table and supplies one
callback, and the library parses, validates and generates the --help, --usage and --version output.
| Piece | Purpose |
|---|---|
|
Name, key (the short option character, or a number for a long-only option), argument name, flags, documentation and
group, ending with an all-zero entry. Flags include |
|
The option table, the parser callback, a one-line description of the operands ( |
Parser callback |
Called with the option key and |
|
Global variables that make argp add |
|
Exit status after a parse error; defaults to |
|
|
|
Print usage or an error through the library, and exit unless |
The program below parses the global options of shelf with argp and leaves the command and its arguments in
ARGP_KEY_ARGS.
/* shelf_argp.c -- the global options of shelf with glibc argp (Linux only). */
#define _GNU_SOURCE
#include <argp.h>
#include <stdio.h>
#include <stdlib.h>
const char *argp_program_version = "shelf 0.3.0"; /* adds --version / -V */
const char *argp_program_bug_address = "<shelf-bugs@example.org>";
static const char doc[] = "Manage a small bookshelf.\vCommands: list, add, import, loan, return, pick.";
static const char args_doc[] = "COMMAND [ARG]...";
static const struct argp_option options[] = {
{ "file", 'f', "FILE", 0, "Catalogue file", 0 },
{ "verbose", 'v', NULL, 0, "Report what is being done on stderr", 0 },
{ "color", 'c', "WHEN", 0, "auto, always or never (default: auto)", 0 },
{ NULL, 0, NULL, 0, NULL, 0 }
};
struct arguments {
const char *file;
const char *color;
int verbose;
char **rest; /* the command and its arguments */
int nrest;
};
static error_t parse_opt(int key, char *arg, struct argp_state *state)
{
struct arguments *a = state->input;
switch (key) {
case 'f': a->file = arg; break;
case 'v': a->verbose = 1; break;
case 'c': a->color = arg; break;
case ARGP_KEY_ARGS: /* everything that is not an option */
a->rest = state->argv + state->next;
a->nrest = state->argc - state->next;
break;
case ARGP_KEY_END:
if (a->nrest == 0) {
argp_usage(state); /* prints usage, exits with EX_USAGE */
}
break;
default:
return ARGP_ERR_UNKNOWN;
}
return 0;
}
static const struct argp argp = { options, parse_opt, args_doc, doc, NULL, NULL, NULL };
int main(int argc, char *argv[])
{
struct arguments a = { NULL, "auto", 0, NULL, 0 };
argp_parse(&argp, argc, argv, ARGP_IN_ORDER, NULL, &a); /* exits on --help, --version and errors */
printf("command = %s, %d argument(s), verbose = %d\n", a.rest[0], a.nrest - 1, a.verbose);
return EXIT_SUCCESS;
}
Source: The GNU C Library — Parsing Program Options with Argp
(see Argp Special Keys and Flags for argp_parse there). This program was not compiled or run: argp does not
exist in the macOS libc. How options that follow the command name behave with ARGP_IN_ORDER is not spelled out in the
manual, so a tool with per-subcommand options should test it on its target libc or use getopt_long with a leading +
as shelf does. The manual says nothing about Windows or musl; programs that must build there bundle a standalone
argp implementation or avoid it.
Choosing
-
A tool that must build everywhere uses
getopt_longplus a short vendored implementation for MSVC, or a library (CLI11 for C++, see Command-Line Programs in C++). -
A tool that only targets glibc and wants generated help takes
argp. -
A tool with three options and no subcommands can use
getoptand the POSIX behaviour. -
Writing the loop by hand over
argvis reasonable for a Windows-first tool that accepts/flagspellings, as long as it still handles--and-the way users expect.
Reading standard input
A filter reads standard input until the end of the file. The C interface is simple and the pitfalls are all in the edges.
| Function | When to use it, and the catch |
|---|---|
|
One line of any length into a growing heap buffer; returns the length or -1. The line may lack its final newline,
and may contain NUL bytes (compare with the returned length, not |
|
Standard C; a fixed buffer. A line longer than the buffer comes back in pieces, so the caller has to detect that the newline is missing; the usual bug is treating the second half of a long line as a new line. |
|
Raw bytes, for binary input and for the raw-mode reader below. |
|
Avoid for lines of text; see Input, Output and Files. |
getline returns -1 both at end of file and on a read error; the two are told apart with ferror afterwards. Interactive
programs also want to know whether anyone is typing: isatty(STDIN_FILENO) (declared in <unistd.h>, _isatty in
<io.h> on Windows) decides whether to print a prompt, and shelf import prints a hint on standard error only when
standard input is a terminal, so a pipeline stays silent. The full non-TTY fallback rules for tools that agents run are
on TTY, Colour & Interactivity.
The read loop of shelf import, including the interruption by a signal described in Signals and cleanup:
static void cmd_import(const struct options *o, int argc, char **argv, struct catalogue *c)
{
if (argc > 2) die(EX_USAGE, "import: at most one FILE");
FILE *in = stdin;
const char *name = "standard input";
if (argc == 2 && strcmp(argv[1], "-") != 0) {
in = fopen(argv[1], "r");
if (in == NULL) die_errno(EX_NOINPUT, "cannot open", argv[1]);
name = argv[1];
} else if (isatty(STDIN_FILENO)) {
fprintf(stderr, "%s: reading from the terminal; finish with Ctrl+D\n", prog);
}
char *line = NULL;
size_t cap = 0;
ssize_t len;
size_t added = 0;
for (;;) {
errno = 0;
len = getline(&line, &cap, in);
if (len == -1) {
if (errno == EINTR && got_signal) die_from_signal();
break; /* EOF or error: told apart below */
}
if (len > 0 && line[len - 1] == '\n') line[len - 1] = '\0';
if (*line == '\0') continue;
char *bar = strchr(line, '|');
if (bar == NULL) die(EX_DATAERR, "%s: missing '|' in '%s'", name, line);
*bar = '\0';
if (has_forbidden(line) || has_forbidden(bar + 1)) die(EX_DATAERR, "%s: control characters are not allowed", name);
push(c, next_id(c), line, bar + 1, "");
added++;
}
if (ferror(in)) die_errno(EX_IOERR, "read error on", name); /* EOF is not an error */
free(line);
if (in != stdin) fclose(in);
save(c, o->db);
if (o->verbose) fprintf(stderr, "%s: imported %zu book(s)\n", prog, added);
}
A smaller program shows the edges on their own. It was run with a final line without a newline, with empty input, with a 100,000-byte line and with a directory as input:
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main(void)
{
if (isatty(STDIN_FILENO)) {
fputs("type titles, one per line; Ctrl+D ends\n", stderr);
}
char *line = NULL;
size_t cap = 0, count = 0, longest = 0;
ssize_t len;
while ((len = getline(&line, &cap, stdin)) != -1) { /* grows the buffer as needed */
if (len > 0 && line[len - 1] == '\n') line[--len] = '\0'; /* the last line may have no \n */
count++;
if ((size_t)len > longest) longest = (size_t)len;
}
if (ferror(stdin)) { /* -1 means EOF *or* error */
perror("read");
return EXIT_FAILURE;
}
printf("%zu line(s), longest %zu byte(s)\n", count, longest);
free(line);
return EXIT_SUCCESS;
}
$ printf 'Dune\nEmma\nUlysses without newline' | ./reader
3 line(s), longest 23 byte(s)
$ ./reader < /dev/null
0 line(s), longest 0 byte(s)
$ python3 -c "print('x' * 100000)" | ./reader
1 line(s), longest 100000 byte(s)
$ ./reader < .
read: Is a directory
Source: POSIX — getline and POSIX — isatty.
Buffering and setvbuf
The C library buffers each stream: standard output is line-buffered when it is a terminal and fully buffered when it is a
pipe or a file, and standard error is unbuffered. The consequences (a progress line that appears late, two streams that
interleave out of order, output lost when a program dies from a signal) are explained on
POSIX Processes, Signals & I/O. setvbuf(stream, buf, mode, size)
changes the mode (_IOFBF fully, _IOLBF line, _IONBF none). It must be called before any other operation on the
stream, and a buffer that the caller supplies must outlive the stream. A filter that has to hand results to the next
program in a pipeline as soon as each line is ready makes standard output line-buffered:
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
/* Echo each title as soon as it arrives, even when stdout is a pipe. */
int main(void)
{
if (setvbuf(stdout, NULL, _IOLBF, 0) != 0) { /* must run before any other use of stdout */
perror("setvbuf");
return EXIT_FAILURE;
}
char *line = NULL;
size_t cap = 0;
while (getline(&line, &cap, stdin) != -1) {
fputs(line, stdout); /* flushed at each newline */
}
free(line);
return ferror(stdin) || fflush(stdout) != 0 ? EXIT_FAILURE : EXIT_SUCCESS;
}
Run as (echo a; sleep 1; echo b) | ./lines | while read l; do echo "$(date +%S) $l"; done, it printed a and b
one second apart; without the setvbuf call both lines arrive together when the program exits. Line-buffering a high-volume
output makes it slower, because each line costs a write system call, so reserve it for the streaming case.
Source: POSIX — setvbuf. Compiled and run on macOS.
On Windows the standard streams are in text mode: \n is written as \r\n, and on input a Ctrl+Z (character 26)
read from the console ends the file. A program that writes binary data or a protocol with exact bytes switches the
descriptor to binary with _setmode(_fileno(stdout), _O_BINARY) (<io.h> and <fcntl.h>).
Reporting errors
errno, perror and strerror
A failing system or library call returns a sentinel (-1 or a null pointer) and sets errno. Three rules cover
most mistakes: read errno only right after a call that reported failure, because successful calls may change it; copy
it before calling anything else (a fprintf can overwrite it, which is why die_errno below saves it first); and never
assign to it except to clear it before strtol-style calls.
| Function | Behaviour |
|---|---|
|
Prints |
|
Returns the message in a string that a later call may overwrite and that other threads share; fine for a single-threaded tool, avoid in libraries. |
|
The thread-safe form, with two incompatible versions: the XSI/POSIX one returns an |
|
BSD functions, also in glibc, not in POSIX and not in the MSVC runtime. They prefix the message with the program name and
|
|
The C11 Annex K / MSVC form on Windows. |
Windows has a second error domain: Win32 functions report failures through GetLastError, not errno, and the CRT
functions use errno. A Windows port has to know which family each call belongs to. The compiled snippet below uses the
UNIX functions and prints what they produce on macOS:
#define _POSIX_C_SOURCE 200809L
#include <err.h> /* BSD and glibc, not POSIX */
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sysexits.h> /* BSD origin, present on Linux and macOS, absent from MSVC */
int main(int argc, char *argv[])
{
(void)argc;
FILE *f = fopen("/nonexistent/books.tsv", "r");
if (f == NULL) {
int saved = errno; /* copy before any call that may change it */
perror("shelf"); /* "shelf: No such file or directory" */
char buf[128];
if (strerror_r(saved, buf, sizeof buf) == 0) { /* XSI form: int return */
printf("strerror_r: %s\n", buf);
}
printf("EX_USAGE=%d EX_DATAERR=%d EX_NOINPUT=%d EX_UNAVAILABLE=%d EX_SOFTWARE=%d EX_IOERR=%d EX_CONFIG=%d\n",
EX_USAGE, EX_DATAERR, EX_NOINPUT, EX_UNAVAILABLE, EX_SOFTWARE, EX_IOERR, EX_CONFIG);
errno = saved;
warn("cannot open %s", argv[0]); /* "<progname>: cannot open <arg>: <strerror>" */
errno = saved;
err(EX_NOINPUT, "giving up"); /* like warn, then exit(EX_NOINPUT) */
}
return EXIT_SUCCESS;
}
$ ./errors
shelf: No such file or directory
errors: cannot open ./errors: No such file or directory
errors: giving up: No such file or directory
$ echo $?
66
The standard output lines (strerror_r: ... and the status table) are printed too; the example above shows only the
standard-error lines, in the order a terminal displays them.
Exit statuses and sysexits.h
<sysexits.h> names the exit codes 64 to 78 that mail transfer agents have used since 4BSD: EX_USAGE (64),
EX_DATAERR (65), EX_NOINPUT (66), EX_UNAVAILABLE (69), EX_SOFTWARE (70), EX_OSERR (71), EX_CANTCREAT (73),
EX_IOERR (74), EX_TEMPFAIL (75), EX_NOPERM (77) and EX_CONFIG (78). The header is present on Linux and macOS and
absent from MSVC. The FreeBSD manual page that documents it marks the interface deprecated, discouraged for new code and
not portable; glibc’s argp nevertheless uses EX_USAGE by default. shelf uses a handful of the names as a
readable vocabulary, with a fallback definition for systems that lack the header (the __has_include test in the listing in
The complete shelf program), and lists them in its --help output so that callers know what to expect. Whether a tool adopts them or just
uses 1 and 2 is a design choice discussed on
Streams & Exit Codes; what matters is that the codes are documented
and stable.
The two helpers that every error in shelf goes through:
static void die(int status, const char *fmt, ...)
{
va_list ap;
fprintf(stderr, "%s: ", prog);
va_start(ap, fmt);
vfprintf(stderr, fmt, ap);
va_end(ap);
fputc('\n', stderr);
exit(status);
}
static void die_errno(int status, const char *what, const char *path)
{
int saved = errno; /* fprintf may change errno */
fprintf(stderr, "%s: %s '%s': %s\n", prog, what, path, strerror(saved));
exit(status);
}
Messages follow the GNU convention program: message, one line, on standard error, and name the object that failed
(cannot open 'books.tsv': No such file or directory).
Errors on the write path
Output errors appear late. fprintf to a full disk may succeed because the data only sits in the buffer; the failure
surfaces at fflush, fclose or exit. shelf therefore checks fflush, ferror and fclose when it writes the catalogue
(see save in The complete shelf program), and checks standard output at the end of main (shown earlier). It writes the new catalogue
to a temporary file created with mkstemp in the same directory (an unpredictable name, mode 0600) and then calls rename to put it over the
catalogue, which replaces the old file atomically, so a crash or a full disk never leaves a half-written catalogue behind.
Source: POSIX — perror, POSIX — strerror, strerror(3), err(3), sysexits(3) and GNU Coding Standards — Command-Line Interfaces. Compiled and run on macOS.
The environment
getenv(name) returns a pointer into the process environment, or a null pointer. The pointer is only valid until the
next call to setenv, putenv or unsetenv, the returned string must not be modified, and getenv is not async-signal-safe.
Copy the value if it has to live longer. setenv(name, value, overwrite) changes the environment of the program and of
the children it starts later; it is not thread-safe against concurrent readers, so a program sets variables before it
starts threads. The complete tour of environ and envp is on
POSIX Processes, Signals & I/O.
Trusting the environment
A set-user-ID or set-group-ID program that trusts getenv hands an attacker control of PATH, HOME or a configuration
directory. glibc 2.17 added secure_getenv, which returns a null pointer when the process runs in secure-execution
mode (set-user-ID, set-group-ID or elevated capabilities), and POSIX.1-2024 standardised it. It is meant for libraries and
for any code that might run in such a process. The macOS libc used for this page does not declare it, so portable code asks
the build system (HAVE_SECURE_GETENV, set by the CMake and Meson files below) and falls back to getenv, as shelf does:
/* CMake or Meson defines HAVE_SECURE_GETENV when the C library provides it (glibc 2.17+). */
#ifdef HAVE_SECURE_GETENV
# define env_get secure_getenv
#else
# define env_get getenv
#endif
Compiling that file with -DHAVE_SECURE_GETENV on macOS fails with an undeclared function, which confirms the point; the
secure_getenv branch itself was not run.
Variables a CLI should read
shelf reads SHELF_HOME for its data location, and the standard ones for presentation: NO_COLOR (any non-empty value
disables colour; a community convention documented at no-color.org, not a standard), and TERM (dumb means no escape
sequences). The precedence rule is always flag over environment over default; the colour decision is:
static int want_color(const char *when)
{
if (strcmp(when, "always") == 0) return 1;
if (strcmp(when, "never") == 0) return 0;
if (strcmp(when, "auto") != 0) die(EX_USAGE, "invalid --color argument '%s'", when);
const char *no_color = env_get("NO_COLOR"), *term = env_get("TERM");
if (no_color != NULL && *no_color != '\0') return 0; /* no-color.org: non-empty disables */
if (term == NULL || strcmp(term, "dumb") == 0) return 0;
return isatty(STDOUT_FILENO);
}
The reasons behind these rules, and the other variables (FORCE_COLOR, CLICOLOR, COLUMNS), are on
TTY, Colour & Interactivity and
Configuration & Auth.
Windows
Environment variable names are case-insensitive on Windows and case-sensitive elsewhere. MSVC marks getenv as unsafe
(warning C4996) and offers _dupenv_s (and _wdupenv_s for UTF-16), which allocate a copy that the caller frees. The
envp array handed to main or wmain is a frozen copy: calling _putenv later changes what getenv returns but not
envp.
Signals and cleanup
A command-line program has resources that must not be left behind: a terminal in raw mode, a temporary file, a lock. The sequence is the same everywhere:
-
A handler records that a signal arrived and does nothing else.
-
The main code sees the flag at a safe point (after an interrupted
readreturnsEINTR, or in the loop’s condition), restores whatever it changed, and exits. -
To keep the correct exit status, the program resets the signal to its default action and sends it to itself, so the parent sees that the program was killed by
SIGINT, and a shell shows128 + 2, which shells and build tools use to stop a loop on Ctrl+C. A program that merely callsexit(1)after Ctrl+C looks like an ordinary failure.
The process model behind this (what a handler may call, SA_RESTART, the self-pipe trick) is on
POSIX Processes, Signals & I/O; the three functions that shelf uses are:
static void on_signal(int sig)
{
got_signal = sig; /* the only thing a handler does */
}
static void install_handlers(void)
{
struct sigaction sa;
memset(&sa, 0, sizeof sa);
sa.sa_handler = on_signal;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0; /* no SA_RESTART: blocking reads see EINTR */
sigaction(SIGINT, &sa, NULL);
sigaction(SIGTERM, &sa, NULL);
sigaction(SIGHUP, &sa, NULL);
sa.sa_handler = SIG_IGN; /* a closed pipe becomes EPIPE */
sigaction(SIGPIPE, &sa, NULL);
}
static void die_from_signal(void)
{
int sig = (int)got_signal;
raw_off();
signal(sig, SIG_DFL);
raise(sig);
_exit(128 + sig); /* only reached if raise returned */
}
Three details are deliberate. sa_flags is 0, without SA_RESTART, so that a blocking read or getline returns
EINTR and the program notices the flag immediately instead of waiting for the next line. SIGPIPE is ignored so that
a closed pipe becomes an EPIPE error from write that the code checks, which is usually easier to handle than dying
silently (tools that behave like classic filters keep the default and die quietly). And got_signal is a
volatile sig_atomic_t, the only kind of object a handler may portably write.
The behaviour was tested on macOS by sending SIGTERM and SIGINT to a shelf import blocked on an empty pipe:
in both cases the process ended as "killed by the signal" (a parent sees -15 and -2; a shell shows 143 and 130), and for the
pick command under a pseudo-terminal the terminal settings were restored first.
Windows has no sigaction. The CRT signal supports a few signals (SIGINT, SIGTERM, SIGABRT and a few more) and
console programs handle Ctrl+C, Ctrl+Break and the closing of the window with SetConsoleCtrlHandler, which runs the handler
on a new thread; the details are on
Windows Console & Processes.
Source: POSIX — sigaction and POSIX — raise.
Reading single keys in raw mode
By default a terminal is in canonical mode: the kernel collects a line, lets the user edit it, and delivers it only when Enter
is pressed. A menu such as shelf pick needs each key at once, which means switching the terminal to non-canonical mode
with termios. The theory (the line discipline, every flag, cfmakeraw) is on
Terminals, TTYs & Pseudo-Terminals; this is the minimal version a C tool needs.
| Setting | Why |
|---|---|
Clear |
Deliver bytes as they are typed instead of whole lines. |
Clear |
The program draws what it wants; the terminal must not echo keys. |
Keep |
Ctrl+C still sends |
|
|
|
Applies the change after pending output is written and discards unread input, so keys typed before the switch do not leak into the menu. |
Restore on every exit path |
|
static void raw_on(void)
{
struct termios t;
if (tcgetattr(STDIN_FILENO, &saved_tio) == -1) {
die_errno(EX_IOERR, "tcgetattr", "stdin");
}
t = saved_tio;
t.c_lflag &= ~(tcflag_t)(ICANON | ECHO); /* keep ISIG: Ctrl+C still works */
t.c_cc[VMIN] = 1;
t.c_cc[VTIME] = 0;
if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &t) == -1) {
die_errno(EX_IOERR, "tcsetattr", "stdin");
}
raw_active = 1;
atexit(raw_off); /* every normal exit path restores */
}
static void raw_off(void)
{
if (raw_active) {
tcsetattr(STDIN_FILENO, TCSAFLUSH, &saved_tio);
raw_active = 0;
}
}
Arrow keys arrive as escape sequences, not single bytes: the sequence ESC [ A for Up, ESC [ B for Down (and
ESC O A and ESC O B when the terminal is in application cursor-key mode). A lone Esc key sends only the byte 27, so the
reader cannot tell "Esc" from "the start of an arrow" without a timeout. shelf waits 50 ms with poll for the next byte:
static int read_key(void)
{
unsigned char ch;
for (;;) {
ssize_t n = read(STDIN_FILENO, &ch, 1);
if (n == 1) break;
if (n == 0) return -1;
if (errno == EINTR) { if (got_signal) die_from_signal(); continue; }
die_errno(EX_IOERR, "read error on", "terminal");
}
if (ch != 0x1b) return ch;
struct pollfd p = { STDIN_FILENO, POLLIN, 0 };
unsigned char seq[2];
if (poll(&p, 1, 50) <= 0 || read(STDIN_FILENO, &seq[0], 1) != 1) return 0x1b; /* a lone Esc */
if (seq[0] != '[' && seq[0] != 'O') return 0x1b;
if (poll(&p, 1, 50) <= 0 || read(STDIN_FILENO, &seq[1], 1) != 1) return 0x1b;
return seq[1] == 'A' ? KEY_UP : seq[1] == 'B' ? KEY_DOWN : 0;
}
The menu itself redraws in place with two escape sequences (\x1b[nA moves the cursor up n lines and \x1b[2K clears a
line) and highlights the selected row with reverse video (\x1b[7m). It draws on standard error and prints only the chosen id
on standard output, so that id=$(shelf pick) works while the menu is shown on the terminal:
static void cmd_pick(const struct options *o, int argc, char **argv, struct catalogue *c)
{
(void)o; (void)argv;
if (argc != 1) die(EX_USAGE, "pick: takes no arguments");
if (!isatty(STDIN_FILENO) || !isatty(STDERR_FILENO)) die(EX_USAGE, "pick needs a terminal on stdin and stderr");
if (c->n == 0) die(EX_NOINPUT, "the shelf is empty");
size_t sel = 0;
raw_on();
for (int first = 1;; first = 0) {
if (!first) fprintf(stderr, "\x1b[%zuA", c->n); /* back to the first row */
for (size_t i = 0; i < c->n; i++) {
fprintf(stderr, "\r\x1b[2K%s%4ld %s\x1b[0m\n", i == sel ? "\x1b[7m" : "", c->v[i].id, c->v[i].title);
}
int k = read_key();
if (k == 'q' || k == 0x1b || k == -1) { raw_off(); exit(EXIT_FAILURE); } /* cancelled */
if (k == '\n' || k == '\r') break;
if ((k == KEY_UP || k == 'k') && sel > 0) sel--;
if ((k == KEY_DOWN || k == 'j') && sel + 1 < c->n) sel++;
}
raw_off();
printf("%ld\n", c->v[sel].id); /* the result goes to stdout */
}
The reader was tested on macOS by driving the program through a pseudo-terminal: Down, Down, Enter printed 3; j, k,
Enter printed 1; q and a lone Esc cancelled with status 1; and a SIGINT while the menu was open ended the process by
that signal with the terminal’s ICANON and ECHO flags restored to their original values.
Pitfalls specific to this reader:
-
Ctrl+Z (
SIGTSTP) is not handled: the shell stops the program, and a tool that wants the terminal back in cooked mode while it is stopped installsSIGTSTPandSIGCONThandlers, as described under job control on Terminals, TTYs & Pseudo-Terminals. -
Never read the keys with
getcharorfgetsafter switching modes. The stdio buffer may already hold bytes read in canonical mode, andread(2)would not see them.shelfusesreadthroughout. -
A resize does not interrupt
read; a full-screen program also handlesSIGWINCH(see Terminals, TTYs & Pseudo-Terminals). -
The decoding is minimal: no Home, End, function keys, modifiers or mouse. Real applications use a library (see C & C++ TUIs: ncurses, PDCurses & FTXUI) or a decoder driven by the terminal’s terminfo entry.
-
Raw mode makes sense only on a terminal.
shelf pickchecksisattyon standard input and standard error and fails with status 64 otherwise, instead of failing insidetcgetattr.
Windows has no termios. The equivalent is the console input mode flags ENABLE_LINE_INPUT, ENABLE_ECHO_INPUT and
ENABLE_PROCESSED_INPUT set with SetConsoleMode, plus ENABLE_VIRTUAL_TERMINAL_INPUT to receive arrow keys as the same
escape sequences as above; the CRT also has _getch and _getwch in <conio.h>. These are covered in the console and
terminals pages (see Windows Console & Processes).
Source: POSIX — tcsetattr, POSIX — termios.h and POSIX — poll. The escape sequences are defined by ECMA-48 and listed in XTerm Control Sequences. Compiled and run on macOS.
Windows: wmain, UTF-8 and the missing pieces
A C command-line program on Windows uses the same main model, with five differences that matter.
Entry point and text encoding
| Approach | How | Consequence |
|---|---|---|
|
Arguments converted to the active ANSI code page |
Characters outside the code page become |
|
Arguments as UTF-16 ( |
Exact text. The program works in UTF-16 or converts to UTF-8 with |
|
The ANSI code page of the process becomes UTF-8 (Windows 10 version 1903 or later) |
The narrow |
The manifest and the console code pages are on Windows Console & Processes.
For output, _setmode(_fileno(stdout), _O_U8TEXT) puts the stream in UTF-8 mode, and the documentation is explicit about
the catch: Unicode modes are meant for the wide printing functions (wprintf, fwprintf), and using a narrow function
such as printf on such a stream triggers an assertion. Call _setmode before any I/O on the stream (flush first if
anything was already written), and print with %ls for wide strings. A short wmain program, compiled with MSVC’s
/utf-8 option, shows all of this together:
/* shelf_win.c -- Windows entry point: wide arguments, UTF-8 console output. */
#include <fcntl.h>
#include <io.h>
#include <stdio.h>
#include <stdlib.h>
#include <wchar.h>
#include <windows.h>
/* _isatty is true for any character device, NUL included; GetConsoleMode is true only for a console. */
static int is_console(FILE *f)
{
DWORD mode;
HANDLE h = (HANDLE)_get_osfhandle(_fileno(f));
return h != INVALID_HANDLE_VALUE && GetConsoleMode(h, &mode);
}
int wmain(int argc, wchar_t *argv[])
{
/* After these calls only the wide printing functions may be used on the two streams. */
if (_setmode(_fileno(stdout), _O_U8TEXT) == -1 || _setmode(_fileno(stderr), _O_U8TEXT) == -1) {
return 74;
}
wchar_t *home = NULL;
size_t len = 0;
if (_wdupenv_s(&home, &len, L"SHELF_HOME") != 0) { /* allocates; the caller frees */
fwprintf(stderr, L"shelf: out of memory\n");
return 70;
}
fwprintf(stdout, L"argc = %d, SHELF_HOME = %ls\n", argc, home != NULL ? home : L"(unset)");
free(home);
for (int i = 1; i < argc; i++) {
fwprintf(stdout, L"argv[%d] = %ls\n", i, argv[i]);
}
fwprintf(stdout, L"stdout is %ls\n", is_console(stdout) ? L"a console" : L"a file or a pipe");
if (fflush(stdout) != 0 || ferror(stdout)) {
return 74;
}
return 0;
}
Source: Microsoft — main function and command-line arguments,
Microsoft — _setmode,
Microsoft — _dupenv_s, _wdupenv_s,
Microsoft — _get_osfhandle and
Microsoft — GetConsoleMode. This program was not compiled or run: no
Windows toolchain was available. The isatty replacement in it follows from the documentation of _isatty, which is true
for any character device (a console, a printer, a serial port, and the NUL device), so a program that wants to know
about a console uses GetConsoleMode as above.
Arguments, wildcards and quoting
The Windows command line is one string; the program splits it, and the C runtime does it by the rules documented in
"Parsing C command-line arguments". The shell does not expand wildcards, so shelf import *.txt hands the program the
literal *.txt unless the program was linked with setargv.obj (wsetargv.obj for wmain), which enables expansion
inside the runtime, or expands the pattern itself with FindFirstFile. Programs ported from UNIX that rely on the
shell’s globbing must do one of these. The details, with CommandLineToArgvW and the quoting of cmd.exe and PowerShell, are
on Windows Console & Processes.
Option parsing without getopt
The MSVC runtime has no getopt, no getopt_long, no argp, no <err.h> and no <sysexits.h>. The options are:
| Option | Notes |
|---|---|
Vendor a |
Small source files with permissive licences exist (the BSD libc one, gnulib’s |
Build with MinGW-w64, Cygwin or MSYS2 |
These toolchains bring their own C library functions, including |
Write a small hand-made loop |
Reasonable when the tool follows Windows spellings ( |
Use C++ and CLI11 or cxxopts |
Header-only parsers that behave the same on every platform; see Command-Line Programs in C++. |
Other differences to plan for
-
The console subsystem. A program that should run in a console links with
/SUBSYSTEM:CONSOLE(the default for amainorwmainprogram); a GUI-subsystem program has no console unless it asks for one. -
Line endings and Ctrl+Z, described under standard input above.
-
Signals and Ctrl+C as described above: use
SetConsoleCtrlHandler. -
Terminal escape sequences work only after enabling
ENABLE_VIRTUAL_TERMINAL_PROCESSINGon the output handle; ashelfthat colours its output enables it first and checks the result ofSetConsoleMode. -
The runtime choice (
/MTstatic or/MDDLL) decides whether the binary needs a redistributable runtime; see Native Binaries & Cross-Compilation.
Source: Microsoft — Parsing C command-line arguments,
Microsoft — /SUBSYSTEM and
Microsoft — /utf-8.
The statement about MinGW-w64, Cygwin and MSYS2 shipping getopt_long describes those toolchains' usual libraries and was
not tested here.
Building with CMake and Meson
For a single file the build command is the compiler line. This is what was run to build and test shelf on macOS; on Linux
add -D_FORTIFY_SOURCE=2 with optimisation if the distribution does not enable it by default:
$ cc -std=c11 -Wall -Wextra -Wpedantic -Wshadow -Wconversion -O2 -o shelf src/shelf.c
$ cc -std=c11 -g -fsanitize=address,undefined -o shelf_asan src/shelf.c # a second build for testing
Anything bigger, or anything that must also build on Windows, uses a build system. The two common ones for C are CMake and Meson. The C Reference page Build and Tooling introduces both; the files below add what a command-line program needs: asking the C library what it provides instead of testing for an operating system, building a different source on Windows, installing the binary and running smoke tests.
cmake_minimum_required(VERSION 3.21)
project(shelf VERSION 0.3.0 LANGUAGES C)
set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)
set(CMAKE_C_EXTENSIONS OFF)
# Ask the C library what it offers instead of testing for a platform.
include(CheckSymbolExists)
list(APPEND CMAKE_REQUIRED_DEFINITIONS -D_GNU_SOURCE)
check_symbol_exists(secure_getenv stdlib.h HAVE_SECURE_GETENV)
check_symbol_exists(argp_parse argp.h HAVE_ARGP)
list(REMOVE_ITEM CMAKE_REQUIRED_DEFINITIONS -D_GNU_SOURCE)
if(WIN32)
add_executable(shelf src/shelf_win.c) # wmain; UTF-8 console output
else()
add_executable(shelf src/shelf.c)
endif()
target_compile_options(shelf PRIVATE
$<$<C_COMPILER_ID:GNU,Clang,AppleClang>:-Wall;-Wextra;-Wpedantic;-Wformat=2>
$<$<C_COMPILER_ID:MSVC>:/W4;/utf-8>)
if(HAVE_SECURE_GETENV)
target_compile_definitions(shelf PRIVATE HAVE_SECURE_GETENV)
endif()
# The argp variant is only built where the C library has argp (glibc).
if(HAVE_ARGP)
add_executable(shelf-argp src/shelf_argp.c)
endif()
install(TARGETS shelf RUNTIME DESTINATION bin)
include(CTest)
if(NOT WIN32)
add_test(NAME version COMMAND shelf --version)
set_tests_properties(version PROPERTIES PASS_REGULAR_EXPRESSION "^shelf 0\\.3\\.0")
add_test(NAME no-command COMMAND shelf)
set_tests_properties(no-command PROPERTIES WILL_FAIL TRUE)
endif()
$ cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
$ cmake --build build
$ ctest --test-dir build --output-on-failure
$ cmake --install build --prefix /usr/local
Source: CMake — CheckSymbolExists, CMake — install and CMake — add_test.
project('shelf', 'c',
version : '0.3.0',
meson_version : '>=1.0',
default_options : ['c_std=c11', 'warning_level=3'])
cc = meson.get_compiler('c')
args = []
if cc.has_function('secure_getenv', prefix : '#define _GNU_SOURCE\n#include <stdlib.h>')
args += '-DHAVE_SECURE_GETENV'
endif
if cc.get_id() == 'msvc'
args += '/utf-8'
endif
if host_machine.system() == 'windows'
src = 'src/shelf_win.c'
else
src = 'src/shelf.c'
endif
shelf = executable('shelf', src, c_args : args, install : true)
# The argp variant is only built where the C library has argp (glibc).
if cc.has_header_symbol('argp.h', 'argp_parse', prefix : '#define _GNU_SOURCE')
executable('shelf-argp', 'src/shelf_argp.c', install : true)
endif
if host_machine.system() != 'windows'
test('version', shelf, args : ['--version'])
test('no-command', shelf, should_fail : true)
endif
$ meson setup build
$ meson compile -C build
$ meson test -C build
$ meson install -C build
Source: Meson — Compiler properties (has_function, has_header_symbol),
Meson — Reference manual: functions (executable, test) and
Meson — Built-in options (c_std, warning_level). The CMake and Meson files were
not run: neither tool was installed where the page was written, so they follow the documentation and have not been
exercised.
Both systems share the same shape: declare facts (symbols, headers), choose sources per platform, set warnings per compiler, install and test. Neither replaces a decision about hardening flags, which belong to the packager:
| Flag | Purpose |
|---|---|
|
The baseline set of warnings (GCC, Clang); |
|
Warns about |
|
AddressSanitizer and UndefinedBehaviorSanitizer for test builds. |
|
Compile-time and run-time checks for unsafe calls such as |
|
Stack canaries and a position-independent executable (Linux distribution defaults). |
|
Full RELRO for the GNU linker (Linux). |
The sanitizers, Valgrind and static-analysis flags are described on Build and Tooling.
The complete shelf program
The listing below is the whole of src/shelf.c, the program that every excerpt above came from. It builds on Linux and macOS
without changes; the Windows entry point is the separate file shown earlier, and the argp variant is a separate file.
/* shelf.c -- the Bookshelf command-line tool in C (POSIX: Linux and macOS). */
#define _GNU_SOURCE /* glibc: getopt_long, secure_getenv (ignored elsewhere) */
#define _DARWIN_C_SOURCE /* macOS: optreset */
#include <errno.h>
#include <getopt.h>
#include <limits.h>
#include <poll.h>
#include <signal.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <termios.h>
#include <unistd.h>
#if defined(__has_include)
# if __has_include(<sysexits.h>)
# include <sysexits.h>
# endif
#endif
#ifndef EX_USAGE /* fallback when the header is missing */
# define EX_USAGE 64
# define EX_DATAERR 65
# define EX_NOINPUT 66
# define EX_SOFTWARE 70
# define EX_CANTCREAT 73
# define EX_IOERR 74
#endif
#define VERSION "0.3.0"
/* CMake or Meson defines HAVE_SECURE_GETENV when the C library provides it (glibc 2.17+). */
#ifdef HAVE_SECURE_GETENV
# define env_get secure_getenv
#else
# define env_get getenv
#endif
static const char *prog = "shelf";
static volatile sig_atomic_t got_signal = 0;
static struct termios saved_tio;
static int raw_active = 0;
/* ---- errors ------------------------------------------------------------ */
static void die(int status, const char *fmt, ...)
{
va_list ap;
fprintf(stderr, "%s: ", prog);
va_start(ap, fmt);
vfprintf(stderr, fmt, ap);
va_end(ap);
fputc('\n', stderr);
exit(status);
}
static void die_errno(int status, const char *what, const char *path)
{
int saved = errno; /* fprintf may change errno */
fprintf(stderr, "%s: %s '%s': %s\n", prog, what, path, strerror(saved));
exit(status);
}
/* ---- terminal ---------------------------------------------------------- */
static void raw_off(void)
{
if (raw_active) {
tcsetattr(STDIN_FILENO, TCSAFLUSH, &saved_tio);
raw_active = 0;
}
}
static void raw_on(void)
{
struct termios t;
if (tcgetattr(STDIN_FILENO, &saved_tio) == -1) {
die_errno(EX_IOERR, "tcgetattr", "stdin");
}
t = saved_tio;
t.c_lflag &= ~(tcflag_t)(ICANON | ECHO); /* keep ISIG: Ctrl+C still works */
t.c_cc[VMIN] = 1;
t.c_cc[VTIME] = 0;
if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &t) == -1) {
die_errno(EX_IOERR, "tcsetattr", "stdin");
}
raw_active = 1;
atexit(raw_off); /* every normal exit path restores */
}
/* ---- signals ----------------------------------------------------------- */
static void on_signal(int sig)
{
got_signal = sig; /* the only thing a handler does */
}
static void install_handlers(void)
{
struct sigaction sa;
memset(&sa, 0, sizeof sa);
sa.sa_handler = on_signal;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0; /* no SA_RESTART: blocking reads see EINTR */
sigaction(SIGINT, &sa, NULL);
sigaction(SIGTERM, &sa, NULL);
sigaction(SIGHUP, &sa, NULL);
sa.sa_handler = SIG_IGN; /* a closed pipe becomes EPIPE */
sigaction(SIGPIPE, &sa, NULL);
}
/* Clean up, then die from the same signal so the shell sees status 128 + N. */
static void die_from_signal(void)
{
int sig = (int)got_signal;
raw_off();
signal(sig, SIG_DFL);
raise(sig);
_exit(128 + sig); /* only reached if raise returned */
}
/* ---- the catalogue ----------------------------------------------------- */
struct book {
long id;
char *title, *author, *loaned_to; /* loaned_to is "" when on the shelf */
};
struct catalogue {
struct book *v;
size_t n, cap;
};
static char *dup_or_die(const char *s)
{
char *p = strdup(s);
if (p == NULL) {
die(EX_SOFTWARE, "out of memory");
}
return p;
}
static void push(struct catalogue *c, long id, const char *t, const char *a, const char *l)
{
if (c->n == c->cap) {
size_t cap = c->cap ? c->cap * 2 : 16;
struct book *v = realloc(c->v, cap * sizeof *v);
if (v == NULL) {
die(EX_SOFTWARE, "out of memory");
}
c->v = v;
c->cap = cap;
}
c->v[c->n++] = (struct book){ id, dup_or_die(t), dup_or_die(a), dup_or_die(l) };
}
static long parse_id(const char *s)
{
char *end;
errno = 0;
long v = strtol(s, &end, 10);
if (end == s || *end != '\0' || errno == ERANGE || v < 1) {
die(EX_USAGE, "invalid book id '%s'", s);
}
return v;
}
static void load(struct catalogue *c, const char *path)
{
FILE *f = fopen(path, "r");
if (f == NULL) {
if (errno == ENOENT) {
return; /* a missing file is an empty shelf */
}
die_errno(EX_NOINPUT, "cannot open", path);
}
char *line = NULL;
size_t cap = 0;
ssize_t len;
while ((len = getline(&line, &cap, f)) != -1) {
if (len > 0 && line[len - 1] == '\n') {
line[len - 1] = '\0';
}
char *id = strtok(line, "\t"), *t = strtok(NULL, "\t");
char *a = strtok(NULL, "\t"), *l = strtok(NULL, "\t");
if (id == NULL || t == NULL || a == NULL) {
die(EX_DATAERR, "%s: malformed line", path);
}
push(c, parse_id(id), t, a, l ? l : "");
}
if (ferror(f)) {
die_errno(EX_IOERR, "read error on", path);
}
free(line);
fclose(f);
}
static void save(const struct catalogue *c, const char *path)
{
size_t need = strlen(path) + 8;
char *tmp_path = malloc(need);
if (tmp_path == NULL) {
die(EX_SOFTWARE, "out of memory");
}
snprintf(tmp_path, need, "%s.XXXXXX", path);
int fd = mkstemp(tmp_path); /* unique name, created with mode 0600 */
FILE *f = fd == -1 ? NULL : fdopen(fd, "w");
if (f == NULL) {
if (fd != -1) {
close(fd);
unlink(tmp_path);
}
die_errno(EX_CANTCREAT, "cannot create", tmp_path);
}
for (size_t i = 0; i < c->n; i++) {
const struct book *b = &c->v[i];
fprintf(f, "%ld\t%s\t%s\t%s\n", b->id, b->title, b->author, b->loaned_to);
}
if (fflush(f) != 0 || ferror(f) || fclose(f) != 0) { /* ENOSPC shows up here */
unlink(tmp_path);
die_errno(EX_IOERR, "write error on", tmp_path);
}
if (rename(tmp_path, path) == -1) { /* atomic replacement */
unlink(tmp_path);
die_errno(EX_IOERR, "cannot rename onto", path);
}
free(tmp_path);
}
/* ---- options ----------------------------------------------------------- */
struct options {
int verbose;
int color; /* 0 never, 1 always */
const char *db;
};
static void usage(FILE *out)
{
fprintf(out,
"Usage: %s [OPTION]... COMMAND [ARG]...\n"
"Manage a small bookshelf.\n\n"
"Commands:\n"
" list [-l] list books (-l: only loaned ones)\n"
" add TITLE AUTHOR add a book\n"
" import [FILE] add books from lines 'Title|Author' (stdin when omitted or -)\n"
" loan ID NAME lend a book\n"
" return ID take a book back\n"
" pick choose a book with the arrow keys (needs a terminal)\n\n"
"Options:\n"
" -f, --file=FILE catalogue file (default: $SHELF_HOME/books.tsv or ./books.tsv)\n"
" -v, --verbose report what is being done on stderr\n"
" --color=WHEN auto, always or never (default: auto)\n"
" -h, --help show this help and exit\n"
" -V, --version show the version and exit\n\n"
"Exit status: 0 success, 64 usage error, 65 bad data, 66 missing input, 74 I/O error.\n",
prog);
}
static int want_color(const char *when)
{
if (strcmp(when, "always") == 0) return 1;
if (strcmp(when, "never") == 0) return 0;
if (strcmp(when, "auto") != 0) die(EX_USAGE, "invalid --color argument '%s'", when);
const char *no_color = env_get("NO_COLOR"), *term = env_get("TERM");
if (no_color != NULL && *no_color != '\0') return 0; /* no-color.org: non-empty disables */
if (term == NULL || strcmp(term, "dumb") == 0) return 0;
return isatty(STDOUT_FILENO);
}
static void reset_getopt(void)
{
#if defined(__APPLE__) || defined(__FreeBSD__) || defined(__OpenBSD__) || defined(__NetBSD__)
optreset = 1;
optind = 1;
#else
optind = 0; /* glibc: 0 also re-initialises internal state */
#endif
}
/* ---- commands ---------------------------------------------------------- */
static void cmd_list(const struct options *o, int argc, char **argv, struct catalogue *c)
{
static const struct option lo[] = { { "loaned", no_argument, NULL, 'l' }, { 0 } };
int only_loaned = 0, ch;
reset_getopt();
while ((ch = getopt_long(argc, argv, "+:l", lo, NULL)) != -1) {
if (ch == 'l') only_loaned = 1;
else die(EX_USAGE, "list: unknown option '%s'", argv[optind - 1]);
}
if (optind != argc) die(EX_USAGE, "list: unexpected argument '%s'", argv[optind]);
printf(o->color ? "\x1b[1m%4s %-32s %-20s %s\x1b[0m\n" : "%4s %-32s %-20s %s\n",
"ID", "TITLE", "AUTHOR", "LOANED TO");
for (size_t i = 0; i < c->n; i++) {
const struct book *b = &c->v[i];
if (only_loaned && *b->loaned_to == '\0') continue;
printf("%4ld %-32s %-20s %s\n", b->id, b->title, b->author, b->loaned_to);
}
}
static long next_id(const struct catalogue *c)
{
long m = 0;
for (size_t i = 0; i < c->n; i++) if (c->v[i].id > m) m = c->v[i].id;
return m + 1;
}
static int has_forbidden(const char *s) /* tabs and newlines break the file format; ESC is an attack */
{
for (; *s != '\0'; s++) {
unsigned char ch = (unsigned char)*s;
if (ch < 0x20 || ch == 0x7f) return 1;
}
return 0;
}
static void cmd_add(const struct options *o, int argc, char **argv, struct catalogue *c)
{
if (argc != 3) die(EX_USAGE, "add: expected TITLE and AUTHOR");
if (has_forbidden(argv[1]) || has_forbidden(argv[2])) die(EX_DATAERR, "add: control characters are not allowed");
long id = next_id(c);
push(c, id, argv[1], argv[2], "");
save(c, o->db);
if (o->verbose) fprintf(stderr, "%s: added book %ld\n", prog, id);
}
static void cmd_import(const struct options *o, int argc, char **argv, struct catalogue *c)
{
if (argc > 2) die(EX_USAGE, "import: at most one FILE");
FILE *in = stdin;
const char *name = "standard input";
if (argc == 2 && strcmp(argv[1], "-") != 0) {
in = fopen(argv[1], "r");
if (in == NULL) die_errno(EX_NOINPUT, "cannot open", argv[1]);
name = argv[1];
} else if (isatty(STDIN_FILENO)) {
fprintf(stderr, "%s: reading from the terminal; finish with Ctrl+D\n", prog);
}
char *line = NULL;
size_t cap = 0;
ssize_t len;
size_t added = 0;
for (;;) {
errno = 0;
len = getline(&line, &cap, in);
if (len == -1) {
if (errno == EINTR && got_signal) die_from_signal();
break; /* EOF or error: told apart below */
}
if (len > 0 && line[len - 1] == '\n') line[len - 1] = '\0';
if (*line == '\0') continue;
char *bar = strchr(line, '|');
if (bar == NULL) die(EX_DATAERR, "%s: missing '|' in '%s'", name, line);
*bar = '\0';
if (has_forbidden(line) || has_forbidden(bar + 1)) die(EX_DATAERR, "%s: control characters are not allowed", name);
push(c, next_id(c), line, bar + 1, "");
added++;
}
if (ferror(in)) die_errno(EX_IOERR, "read error on", name); /* EOF is not an error */
free(line);
if (in != stdin) fclose(in);
save(c, o->db);
if (o->verbose) fprintf(stderr, "%s: imported %zu book(s)\n", prog, added);
}
static struct book *find(struct catalogue *c, long id)
{
for (size_t i = 0; i < c->n; i++) if (c->v[i].id == id) return &c->v[i];
die(EX_DATAERR, "no book with id %ld", id);
return NULL;
}
static void cmd_loan(const struct options *o, int argc, char **argv, struct catalogue *c)
{
if (argc != 3) die(EX_USAGE, "loan: expected ID and NAME");
struct book *b = find(c, parse_id(argv[1]));
if (*b->loaned_to != '\0') die(EX_DATAERR, "book %ld is already loaned to %s", b->id, b->loaned_to);
if (has_forbidden(argv[2])) die(EX_DATAERR, "loan: control characters are not allowed");
free(b->loaned_to);
b->loaned_to = dup_or_die(argv[2]);
save(c, o->db);
}
static void cmd_return(const struct options *o, int argc, char **argv, struct catalogue *c)
{
if (argc != 2) die(EX_USAGE, "return: expected ID");
struct book *b = find(c, parse_id(argv[1]));
free(b->loaned_to);
b->loaned_to = dup_or_die("");
save(c, o->db);
}
/* Key reader: returns a byte, or 1000 + n for the arrow keys, or -1 on EOF. */
enum { KEY_UP = 1001, KEY_DOWN = 1002 };
static int read_key(void)
{
unsigned char ch;
for (;;) {
ssize_t n = read(STDIN_FILENO, &ch, 1);
if (n == 1) break;
if (n == 0) return -1;
if (errno == EINTR) { if (got_signal) die_from_signal(); continue; }
die_errno(EX_IOERR, "read error on", "terminal");
}
if (ch != 0x1b) return ch;
struct pollfd p = { STDIN_FILENO, POLLIN, 0 };
unsigned char seq[2];
if (poll(&p, 1, 50) <= 0 || read(STDIN_FILENO, &seq[0], 1) != 1) return 0x1b; /* a lone Esc */
if (seq[0] != '[' && seq[0] != 'O') return 0x1b;
if (poll(&p, 1, 50) <= 0 || read(STDIN_FILENO, &seq[1], 1) != 1) return 0x1b;
return seq[1] == 'A' ? KEY_UP : seq[1] == 'B' ? KEY_DOWN : 0;
}
static void cmd_pick(const struct options *o, int argc, char **argv, struct catalogue *c)
{
(void)o; (void)argv;
if (argc != 1) die(EX_USAGE, "pick: takes no arguments");
if (!isatty(STDIN_FILENO) || !isatty(STDERR_FILENO)) die(EX_USAGE, "pick needs a terminal on stdin and stderr");
if (c->n == 0) die(EX_NOINPUT, "the shelf is empty");
size_t sel = 0;
raw_on();
for (int first = 1;; first = 0) {
if (!first) fprintf(stderr, "\x1b[%zuA", c->n); /* back to the first row */
for (size_t i = 0; i < c->n; i++) {
fprintf(stderr, "\r\x1b[2K%s%4ld %s\x1b[0m\n", i == sel ? "\x1b[7m" : "", c->v[i].id, c->v[i].title);
}
int k = read_key();
if (k == 'q' || k == 0x1b || k == -1) { raw_off(); exit(EXIT_FAILURE); } /* cancelled */
if (k == '\n' || k == '\r') break;
if ((k == KEY_UP || k == 'k') && sel > 0) sel--;
if ((k == KEY_DOWN || k == 'j') && sel + 1 < c->n) sel++;
}
raw_off();
printf("%ld\n", c->v[sel].id); /* the result goes to stdout */
}
/* ---- main -------------------------------------------------------------- */
int main(int argc, char *argv[])
{
const char *slash = strrchr(argv[0], '/');
prog = slash ? slash + 1 : argv[0];
enum { OPT_COLOR = 256 };
static const struct option long_opts[] = {
{ "file", required_argument, NULL, 'f' },
{ "verbose", no_argument, NULL, 'v' },
{ "color", required_argument, NULL, OPT_COLOR },
{ "help", no_argument, NULL, 'h' },
{ "version", no_argument, NULL, 'V' },
{ 0 }
};
struct options o = { 0, 0, NULL };
const char *color_when = "auto";
int ch;
while ((ch = getopt_long(argc, argv, "+:f:vhV", long_opts, NULL)) != -1) {
switch (ch) {
case 'f': o.db = optarg; break;
case 'v': o.verbose = 1; break;
case OPT_COLOR: color_when = optarg; break;
case 'h': usage(stdout); return EXIT_SUCCESS;
case 'V': printf("%s %s\n", prog, VERSION); return EXIT_SUCCESS;
case ':': die(EX_USAGE, "option '%s' needs an argument", argv[optind - 1]);
default: die(EX_USAGE, "unknown option '%s' (try --help)", argv[optind - 1]);
}
}
argc -= optind;
argv += optind;
if (argc == 0) { usage(stderr); return EX_USAGE; }
o.color = want_color(color_when);
char path[PATH_MAX];
if (o.db == NULL) {
const char *home = env_get("SHELF_HOME");
int n = snprintf(path, sizeof path, "%s%sbooks.tsv", home ? home : "", home ? "/" : "");
if (n < 0 || (size_t)n >= sizeof path) die(EX_USAGE, "SHELF_HOME is too long");
o.db = path;
}
install_handlers();
struct catalogue c = { 0 };
load(&c, o.db);
const char *cmd = argv[0];
if (strcmp(cmd, "list") == 0) cmd_list(&o, argc, argv, &c);
else if (strcmp(cmd, "add") == 0) cmd_add(&o, argc, argv, &c);
else if (strcmp(cmd, "import") == 0) cmd_import(&o, argc, argv, &c);
else if (strcmp(cmd, "loan") == 0) cmd_loan(&o, argc, argv, &c);
else if (strcmp(cmd, "return") == 0) cmd_return(&o, argc, argv, &c);
else if (strcmp(cmd, "pick") == 0) cmd_pick(&o, argc, argv, &c);
else die(EX_USAGE, "unknown command '%s' (try --help)", cmd);
if (fflush(stdout) != 0 || ferror(stdout)) { /* `shelf list > /dev/full` */
if (errno != EPIPE) { /* a closed pipe is the reader's choice */
fprintf(stderr, "%s: write error: %s\n", prog, strerror(errno));
}
return EX_IOERR;
}
return EXIT_SUCCESS;
}
A final session with the compiled program, as run on macOS:
$ export SHELF_HOME=$PWD
$ shelf add "Dune" "Frank Herbert"
$ printf 'Emma|Jane Austen\nUlysses|James Joyce\n' | shelf -v import
shelf: imported 2 book(s)
$ shelf loan 2 Ana
$ shelf list
ID TITLE AUTHOR LOANED TO
1 Dune Frank Herbert
2 Emma Jane Austen Ana
3 Ulysses James Joyce
$ shelf loan 2 Bob; echo "exit=$?"
shelf: book 2 is already loaned to Ana
exit=65
$ shelf return x; echo "exit=$?"
shelf: invalid book id 'x'
exit=64
$ shelf --color=sepia list; echo "exit=$?"
shelf: invalid --color argument 'sepia'
exit=64
$ printf 'Bad\033[2J|X\n' | shelf import; echo "exit=$?"
shelf: standard input: control characters are not allowed
exit=65
Source: the functions used are documented in the references below; the program itself is original.
Pitfalls and security notes
-
Never pass user data as a format string.
printf(argv[1])lets the caller read memory with%sand write it with%n. Alwaysprintf("%s", argv[1]); compile with-Wformat=2. -
Check every conversion and every allocation.
strtolwitherrnoandendptr, notatoi;snprintfwith its result compared to the buffer size (shelfrejects an over-longSHELF_HOMEinstead of silently truncating a path);reallocinto a temporary pointer. -
Do not let untrusted text reach the terminal as-is. A book title containing the byte 27 can clear the screen, move the cursor, change the window title or, on terminals that support it, change the clipboard.
shelfrejects control characters when data enters the catalogue; a tool that displays text from elsewhere filters it on output. The escape sequences involved are on Escape Sequences & Terminal Capabilities. -
Create files safely. A predictable temporary name in a shared directory lets another user pre-create a symbolic link there. Use
mkstemp(orO_CREAT | O_EXCL), keep the temporary file in the destination directory so thatrenameis atomic, and decide the permissions on purpose: the fileshelfwrites is private (mode 0600) becausemkstempcreates it that way. -
Treat the environment and
argv[0]as input. Validate paths fromSHELF_HOME; usesecure_getenvin anything that may run set-user-ID; never build a shell command from them forsystemorpopen. Useposix_spawnorexecwith an argument array (see POSIX Processes, Signals & I/O). -
Put
--before file names that come from a variable. A name starting with-is read as an option otherwise. -
Keep handlers tiny. Set a
volatile sig_atomic_t, nothing else; restore the terminal in normal code. -
Restore the terminal on every path and never leave it in raw mode when the program is interrupted or crashes.
-
Check the final flush (
fflush,ferror) so that a full disk or a closed pipe produces the right status. -
Do not assume
charis UTF-8 and one column wide. Measuring text for layout needswcwidth-style tables; padding with%-32sinprintfcounts bytes, so the columns ofshelf listmisalign for titles with accents or CJK characters. See Escape Sequences & Terminal Capabilities. -
Do not rely on library messages. The text printed by
getoptfor an unknown option differs by platform; print your own.
What the official documentation does not provide
-
POSIX standardises
getoptbut notgetopt_long,argp,<err.h>or<sysexits.h>; their behaviour is documented by glibc, the BSD manual pages and the Linux man-pages project, and the BSD page discouragessysexits.hfor new code. -
The glibc manual does not say how
argpbehaves on Windows, musl or macOS (where it is absent), and says little about mixingARGP_IN_ORDERwith subcommand options. -
Microsoft documents
wmain,_setmodeand the Unicode modes, but gives no single recommended recipe for a UTF-8 command-line program in C; the combinations on this page (wmainwith wide output, or the UTF-8 manifest with narrow functions) are assembled from separate documentation pages. -
NO_COLORand the other colour variables are community conventions without a standard, and there is no portable way to ask a terminal whether it supports colour beyondTERMandisatty. -
Neither CMake nor Meson documents command-line-specific guidance; the build files follow their general feature-test and install documentation.
References
-
The GNU C Library manual: Getopt Long Options, Argp, Argp Special Keys, Argp Flags, Standard Environment Variables, Exit Status
-
sysexits(3), FreeBSD manual page
-
Microsoft — main function and command-line arguments (main, wmain, setargv.obj)
-
Meson — Compiler properties, Reference manual: functions, Built-in options
-
NO_COLOR (a community convention)