Running C at compile time

ccomptime wraps clang so ordinary C can run during compilation and write the code that gets compiled.

C has one tool for doing work before your program exists: the preprocessor. It can paste text together, but it can’t loop over a file, call a function or make a decision based on data. For everything else there are code generators, build scripts and checked-in files that go stale.

ccomptime is my attempt at something simpler. It is a small wrapper around the compiler that lets plain C run during compilation, in the same file as the code it generates. You put it in front of your usual command and nothing else changes.

ccomptime clang -o program main.c

A value

A _Comptime block is ordinary C. Whatever it writes to Inline takes its place in the source.

#include <stdio.h>
#include "ccomptime.h"

int fib(int n) { return n < 2 ? n : fib(n - 1) + fib(n - 2); }

int main(void) {
  int x = _Comptime(_ComptimeCtx.Inline.appendf("%d", fib(40)));
  printf("%d\n", x);
}

fib is a normal function in the same file, and the block simply calls it. The compiled program never runs fib(40); it contains the literal 102334155.

Code from a file

Blocks aren’t limited to pure functions. They can open files, allocate memory and print, which is where this gets useful. Anything written to TopLevel is placed ahead of the file, so a block can declare types and functions for the rest of the program to use.

Given a text file with one error name per line, this generates an enum and a function that names each value:

int main(void) {
  _Comptime({
    FILE *f = fopen("errors.txt", "r");
    char name[64];

    _ComptimeCtx.TopLevel.appendf("typedef enum {\n");
    while (fscanf(f, "%63s", name) == 1)
      _ComptimeCtx.TopLevel.appendf("  %s,\n", name);
    _ComptimeCtx.TopLevel.appendf("} Error;\n");

    rewind(f);
    _ComptimeCtx.TopLevel.appendf("const char *error_name(Error e) {\n  switch (e) {\n");
    while (fscanf(f, "%63s", name) == 1)
      _ComptimeCtx.TopLevel.appendf("  case %s: return \"%s\";\n", name, name);
    _ComptimeCtx.TopLevel.appendf("  }\n  return \"?\";\n}\n");

    fclose(f);
  });

  printf("%s\n", error_name(ERR_TIMEOUT));
}

The same pattern turns a CSV into an array of structs, or embeds a binary asset as a byte array, without an extra step in the build.

Types

_ComptimeType works the same way, but in places where C expects a type. Combined with an ordinary macro, it is enough for a small generic:

void result_type(_ComptimeCtx ctx, const char *ok, const char *err) {
  static char seen[64][64];
  static int count;
  char name[64];
  snprintf(name, sizeof name, "Result_%s_%s", ok, err);
  for (char *c = name; *c; c++) if (*c == '*') *c = 'p';

  int found = 0;
  for (int i = 0; i < count; i++) found |= !strcmp(seen[i], name);
  if (!found) {
    strcpy(seen[count++], name);
    ctx.TopLevel.appendf(
        "typedef struct { int ok; union { %s value; %s error; }; } %s;\n",
        ok, err, name);
  }
  ctx.Inline.appendf("%s", name);
}

#define Result(ok, err) _ComptimeType({ result_type(_ComptimeCtx, ok, err); })

Result("int", "char*") parse_digit(char c) {
  if (c >= '0' && c <= '9')
    return (Result("int", "char*")){.ok = 1, .value = c - '0'};
  return (Result("int", "char*")){.ok = 0, .error = "not a digit"};
}

Each distinct pair of types defines its struct once, and every use of Result("int", "char*") becomes the same type name. It is a template, written in C, run by C.

How it works

There is no new compiler here, only a few passes around the existing one.

  1. The source is parsed with tree-sitter, and macros that contain comptime blocks are expanded, so a block can hide behind a macro like Result.
  2. Each block becomes a function in a separate runner program. The runner also includes your file, with main renamed and anything that depends on generated code removed, which is why blocks can call your own functions.
  3. The runner is compiled and run. Each block appends to two buffers: Inline, for the spot where the block stands, and TopLevel, for declarations.
  4. The results are written to a header as numbered macros. In the final build, _Comptime(...) expands through __COUNTER__ to the macro with its number, so every block is replaced by exactly what it wrote.
  5. The header is injected ahead of your file, and the real compiler runs with your original arguments.

Nothing in C is off limits along the way. You keep every footgun the language gives you, now at compile time too.

Compared to

Zig’s comptime is the obvious inspiration and far more complete, but it means writing Zig, and I/O at compile time is awkward. C++ constexpr is limited to pure functions. Templates and build-time generators can get the same results, at the cost of a second language or a second step.

ccomptime is unfinished and unstable, and some of its tests still fail. Still, generating C with C, in the file where it is used, has turned out to be a surprisingly pleasant way to write it. The source is on GitHub, and it builds with only a C compiler:

cc nob.c -o nob
./nob
./build/ccomptime clang -o program main.c