sp.h is an ergonomic, portable, single header standard library that turns C into a modern language that is a pleasure to use. It provides:
sp_fmt, a type-safe{:^16 .cyan}replacement,sp_fmt_cstr("printf")sp_str_t, a proper zero-copy string library and typesp_fs, a std::filesystem-like APIsp_da, a dynamic arraysp_ht, hash tables with arbitrary keys and values (including strings)sp_ps, subprocessessp_io, synchronous IO on top of files and buffers- File monitors, beautiful, interactive CLI prompts, ELF parsing, memory allocators, concurrency, UTF-8, globbing, and a whole lot more!
It's written in ~15,000 lines of plain C991 and has zero dependencies. It can be used with virtually any 64-bit environment and toolchain:
- Linux, macOS, Windows
- x86, ARM, or WASM
- gcc, clang, MSVC, mingw, zig cc, tcc, cosmocc
- musl, glibc, cosmopolitan, MSVCRT, UCRT, WASI, or completely freestanding!
sp.h is a single header library. Download the file and include it as you would any other .h file. Then, in one C file:
#define SP_IMPLEMENTATION
#include "sp.h"sp.h can be also be compiled as a traditional shared or static library.
Here's a minimal ls in 30 lines of code.
#define SP_IMPLEMENTATION
#include "sp.h"
s32 compare_entries(const void* pa, const void* pb) {
const sp_fs_entry_t* a = pa;
const sp_fs_entry_t* b = pb;
return sp_str_compare_alphabetical(a->name, b->name);
}
s32 main(s32 num_args, const c8** args) {
sp_str_t cwd = sp_fs_get_cwd();
sp_str_t dir = cwd;
if (num_args == 2) dir = sp_fs_join_path(cwd, sp_str_view(args[1]));
sp_da(sp_fs_entry_t) entries = sp_fs_collect(dir);
sp_da_sort(entries, compare_entries);
sp_da_for(entries, it) {
sp_fs_entry_t* entry = &entries[it];
switch (entry->kind) {
case SP_FS_KIND_DIR: sp_log("{.fg blue}", sp_fmt_str(entry->name)); break;
case SP_FS_KIND_FILE: sp_log("{}", sp_fmt_str(entry->name)); break;
case SP_FS_KIND_SYMLINK: {
sp_str_t target = sp_fs_canonicalize_path(entry->path);
sp_log("{.fg cyan} -> {}", sp_fmt_str(entry->name), sp_fmt_str(target));
break;
}
case SP_FS_KIND_NONE: break;
}
}
}A few modules showcased in this example:
sp_str_tis a non-null-terminated string which trivially gives us views, substrings, and many path operationssp_fsis more or less equivalent tostd::fsin C++, but in plain C and implemented against the lowest level APIssp_dais astd::vectorequivalent which can hold arbitrary types, does not need initialization, and is stored asT*sp_fmtimplements modern format strings (like Zig, or Rust) whose arguments are type-safe
These are always available in the base sp.h, on every platform2.
| module | description | notes |
|---|---|---|
sp_app |
Minimal, game-style main loop | See e.g. SDL_MAIN_USE_CALLBACKS, sokol_app.h; no window, timing only |
sp_atomic |
Atomic pointers and integers implemented with compiler intrinsics | |
sp_context |
Thread-local allocators and scratch memory | In lieu of allocator_t* arguments at every call site; Fleury- or Blow-esque to those familiar |
sp_cv |
* Condition variables |
|
sp_da |
STB-style resizable array implemented as a plain T* with intrusive header |
Just a pointer (e.g. sp_da(u32) -> u32*), and therefore work as plain C arrays |
sp_env |
Environment variables | PEB parsing on Windows; grabbed from kernel on startup when freestanding; libc otherwise. |
sp_fmt |
A type-safe {.cyan} replacement, sp_fmt_cstr("printf") |
|
sp_fmon |
Filesystem watching using low level backends | inotify on Linux, kqueue or FSEvents on macOS, overlapped IO on Windows |
sp_fs |
Synchronous file utilities; path manipulation | |
sp_hash |
A pseudorandom hash if you're in a pinch | |
sp_ht |
STB-style hash tables with arbitrary keys, values, and hash + compare functions | No _Generic; poor man's monomorphization with just a few (hopefully sane) macros |
sp_io |
Synchronous IO abstraction on top of files and buffers | sp_io_reader_t + sp_io_writer_t for buffered or unbuffered reads and writes; vtable backends for file, fixed-size buffers, and growable buffers |
sp_mem |
Allocators, fundamental memory APIs | |
sp_mutex |
* A minimal mutex API |
|
sp_os |
A grab bag of platform bullshit | sp_os defines a slightly higher level platform backend than sp_sys; more akin to polyfills than syscalls |
sp_ps |
Straightforward pollable subprocesses with IO capturing + redirection | |
sp_rb |
A single-threaded ring buffer (T* + intrusive header) |
|
sp_semaphore |
* A minimal counting semaphore |
|
sp_spin |
Efficient spin lock with pausing | |
sp_str |
Pointer + length strings; not null terminated, many (zero copy) utilities | Worth the price of admission by itself. Free yourself from const char* and write C that looks like Python |
sp_sys |
Low level platform backends, plus syscall helpers | sp_sys defines the platform backend by providing an interface that looks like Linux syscalls |
sp_thread |
* A minimal thread API |
Regrettably implemented against pthread rather than clone()3 |
sp_time |
High resolution timers, date/time, epoch time, unit conversion | |
sp_utf8 |
Encode, decode, validate, and iterate UTF-8 strings |
*A thin wrapper on top of Windows API andpthread
These are available in sp/*.h as separate headers, for various reasons.4
| module | description | notes |
|---|---|---|
sp_math |
What if we took Handmade Math, stripped out the C11 and C++ and slapped sp_ in front of what was left, and then added a couple nice-to-haves on top? |
|
sp_asset |
A minimally asynchronous asset loader and registry, often for games. | |
sp_elf |
Read, parse, modify, and write ELF binaries. | |
sp_msvc |
Where is it? Where is my Visual Studio and why am I hand-parsing JSON to find it? | |
sp_prompt |
Very beautiful clack-inspired interactive prompts for CLIs |
- Prefer the lowest level interface to the OS by default
- Ergonomics are the most important thing and by a lot
- Errors are propagated up the call stack
- Keep macros sane, and never generate code with an external tool
- Null terminated strings are the devil's work and are to be shunned
- A little Assembly never hurt anyone
sp.h is a library that grows with my understanding of systems programming. That means that some of the code is naive, underspecified, or just bad. Everything that exists is tested extremely thoroughly.
| module | problem | platform |
|---|---|---|
sp_ps |
Implemented with pthread instead of fork + exec |
Linux |
sp_ht |
Keys, by default, are simply memcmp'd for equality. If your key is a struct which the compiler pads, it is silently wrong. |
|
sp_io |
Writes are objectively worse than libc, because we don't use writev to batch when we know we want to do more than one write (e.g. "flush the buffer and then immediately write the requested data") |
Source code is often the best documentation, but here are a few more.
Here's a minimal version of a word frequency counter. It uses a few very handy and common functions:
- We use
sp_fs_join_path()to find the target's absolute path - Then, read it in one go with
sp_io_read_file()(a thin wrapper oversp_io_reader_t) - Split the content into lines, and then words. This is all zero copy;
linesandwordscontain views into the content. - A
sp_str_ht(u32)(str->u32) keeps the counts.sp_str_ht_for_kv()lets us iterate with a strongly typed (!) iterator
#define SP_IMPLEMENTATION
#include "sp.h"
s32 main(s32 num_args, const c8** args) {
if (num_args < 2) {
sp_log("usage: wc {.fg cyan}", sp_fmt_cstr("$file"));
return 1;
}
sp_str_t path = sp_fs_join_path(sp_fs_get_cwd(), sp_str_view(args[1]));
sp_str_t content = sp_zero;
sp_io_read_file(path, &content);
sp_str_ht(u32) counts = sp_zero;
sp_da(sp_str_t) lines = sp_str_split_c8(content, '\n');
sp_da_for(lines, i) {
sp_da(sp_str_t) words = sp_str_split_c8(lines[i], ' ');
sp_da_for(words, j) {
u32* count = sp_str_ht_get(counts, words[j]);
if (count) {
*count = *count + 1;
} else {
sp_str_ht_insert(counts, words[j], 1);
}
}
}
sp_str_ht_for_kv(counts, it) {
sp_log("{} {}", sp_fmt_uint(*it.val), sp_fmt_str(*it.key));
}
return 0;
}sp_da(u32) years = sp_zero;
sp_da_push(years, 1969);
sp_da_push(years, 1972);
sp_da_for(years, it) {
sp_log("{}", sp_fmt_uint(years[it]));
} sp_cstr_ht(s32) ht = sp_zero;
sp_cstr_ht_insert(ht, "veneta", 72);
s32* veneta = sp_cstr_ht_get(ht, "veneta");
sp_log("the best dead show was in 19{}", sp_fmt_int(*veneta));Install any C compiler, and then:
make
./build/test/amalgYou can cross compile to any target if you have Zig installed:
make x86_64-linux-noneFootnotes
-
Well, almost. There are still a handful of POSIX calls floating around, and one (unfortunately) hard to rip out GNU extension which is (fortunately) nevertheless implemented by MUSL. ↩
-
A few are unimplemented on freestanding Linux (mostly stuff from
pthread) ↩ -
Albeit more understandably, I'd say. Implementing subprocesses without POSIX sounds like a weekend's project to get something reasonable. Implementing threads without POSIX sounds like maybe you just ought to write your own language... ↩
-
Too niche, too platform specific, too poorly tested, and fairly often, too shitty. ↩