D Developer Guidelines: Integrating Third-Party C Libraries (ImportC)
Overview
This project binds C libraries with ImportC — the C compiler built into DMD and LDC — instead of hand-written or generated bindings. The reference example is sparkles:ghostty, which binds libghostty-vt.
Why ImportC:
- The D compiler parses the real C header, so struct layout, enum values, and function signatures can never drift from the upstream library. There is no
dstep/ctodregeneration step to keep in sync. - The exact header version is pinned by
flake.lock, so ABI fidelity is guaranteed by construction.
Making a C library usable from D in this repo has four moving parts. Get all four right and there is no hardcoded include path anywhere — dub discovers it from pkg-config:
| Part | What it does | Where |
|---|---|---|
ImportC shim (c.c) | #includes the header; becomes a D module | libs/<name>/src/sparkles/<name>/c.c |
dub.sdl | compiles the .c, resolves flags via pkg-config | libs/<name>/dub.sdl |
| pkg-config | supplies --libs (link) and --cflags (ImportC include) | upstream .pc file |
| Nix devshell | puts the library, headers, .pc, and pkg-config on PATH | nix/shells/default.nix, flake.nix |
The canonical example: libs/ghostty
1. The ImportC shim
libs/ghostty/src/sparkles/ghostty/c.c:
// Mark every declaration `nothrow @nogc` on the D side; `pure` is omitted
// because these calls mutate terminal state. See dlang.org/spec/importc#pragma.
#pragma attribute(push, nogc, nothrow)
#include <ghostty/vt.h>
#pragma attribute(pop)- The file name becomes the D module.
c.c→sparkles.ghostty.c. Consumers writeimport sparkles.ghostty.c;and every declaration in the header is a callable D symbol (allextern(C)automatically). - ImportC compiles this
.cdirectly — a.c/.ifile in the source tree is picked up and compiled with no extra directive. #pragma attribute(push, …)(spec) stamps attributes onto every C declaration so callers can stay in@nogc nothrowcode. Supported storage classes:nogc,nothrow,pure(unrecognized ones are ignored). Omitpurefor any library that holds state. Do not cast C function pointers to fake attributes — use the pragma; it is the only honest way to give the bindings@nogc/nothrow.- Keep the shim to just the wrapped
#include(s). Put any D-side conveniences (RAII wrappers, helpers) in separate.dmodules.
2. The dub.sdl
libs/ghostty/dub.sdl (trimmed):
name "ghostty"
dflags "-preview=in" "-preview=dip1000"
libs "ghostty-vt"
configuration "library" {
targetType "sourceLibrary"
}Three things matter:
libs "ghostty-vt"— the link-library name. dub resolves it through pkg-config (next section), which supplies both the link flags and the ImportC include path.targetType "sourceLibrary"— required for the pkg-config include to reach ImportC. See the gotcha.- The
.cshim is auto-discovered; nocSourcePathsneeded when it sits next to the.dsources.
3. How dub feeds pkg-config into ImportC
dub compiles .c/.i files with the D compiler (ImportC) automatically. For libraries resolved via pkg-config (dub#3085), resolveLibs() does this for each libs entry:
pkg-config --exists <name>, thenpkg-config --exists lib<name>— soghostty-vtmatches thelibghostty-vt.pcpackage.pkg-config --libs <pkg>→ linker flags (-L…,-l…).pkg-config --cflags <pkg>→ C preprocessor flags, each prefixed with-Pso-I/…/includebecomes-P-I/…/include— exactly what ImportC's preprocessor needs to resolve<ghostty/vt.h>.
The result: one libs "ghostty-vt" line configures linking and the ImportC include path automatically, with no hardcoded -I.
Related explicit directives (dub#2544, #2818), for libraries that ship no.pc file:
cImportPaths "<dir>"— C header search directories.cSourcePaths "<dir>"— directories of.csources to compile.
Prefer pkg-config; reach for cImportPaths only when there is no .pc (see below).
The sourceLibrary gotcha (read this)
dub's
resolveLibs()(source/dub/compilers/utils.d) clearslibsand skips pkg-config forlibraryandstaticLibrarytargets — it defers library flags to the final executable link.
Consequence: the pkg-config --cflags (i.e. the ImportC include path) are applied only where the package is compiled into an executable or test target.
- If the binding is
targetType "library", its.cis compiled during the library build → no pkg-config runs → ImportC cannot find the header, unless some ambient env (e.g. nix'sNIX_CFLAGS_COMPILE) happens to supply it. That is fragile and non-portable. - Making the binding
targetType "sourceLibrary"means its sources are compiled inside each dependent's (executable/test) build, whereresolveLibs()runs pkg-config and the-P-Iflags reach ImportC. This is whysparkles:ghosttyis asourceLibrary. - The
unittestconfiguration builds a test-runner executable, sodub test :ghosttyalso gets pkg-config and works.
4. Environment (Nix)
The library, its headers, and its .pc must be discoverable: pkg-config on PATH and PKG_CONFIG_PATH pointing at the .pc.
In nix/shells/default.nix, add to the devshell packages:
pkgs.pkg-config
inputs'.ghostty.packages.libghostty-vt # runtime lib (.so + .pc)
inputs'.ghostty.packages.libghostty-vt.dev # headers + dev .pc → PKG_CONFIG_PATHThe
.devoutput carries the headers and the development.pc. Without it, pkg-config can't find the package and ImportC can't find the header.If the upstream is itself a flake, add it as an input in
flake.nixwithfollowsto dedupenixpkgs/systems:nixghostty = { url = "github:ghostty-org/ghostty"; inputs.nixpkgs.follows = "nixpkgs"; inputs.systems.follows = "systems"; inputs.flake-compat.follows = "flake-compat"; inputs.home-manager.follows = "home-manager"; };For a plain nixpkgs library, just use
pkgs.<lib>andpkgs.<lib>.dev.git addnew files before building under Nix. The flake evaluates the git tree, so a freshly createdc.c/dub.sdlis invisible tonix developuntil staged (see AGENTS.md).
5. Toolchain requirements & gotchas
- dub version. The pkg-config
--cflags→-Pbehavior needs a dub that includes dub#3085. The repo pins it viadlang-nixinnix/d-toolchain.nix(dub-1_43_0-alpha-5efed36). An older dub silently won't pass the include to ImportC. - DMD ImportC vs GCC 15 headers. DMD's ImportC preprocessor can't parse GCC 15's
stddef.h(the C23nullptrkeyword is unknown to the D parser).nix/d-toolchain.nixoverridesdmdto build againstgcc14Stdenvon GNU systems; macOS uses clang and is unaffected. If you seenullptr-related ImportC parse errors, this is why — use LDC or the gcc14dmd. - Don't be fooled by
NIX_CFLAGS_COMPILE. ImportC shells out to the nixccwrapper, whoseNIX_CFLAGS_COMPILEalso satisfies includes inside the devshell. That can mask a missing pkg-config setup that fails outside Nix — always run the verification below.
Verifying pkg-config is doing the work
Inside the devshell:
$ dub build :terminal -v 2>&1 | grep pkg-config
Using pkg-config to resolve C preprocessor flags for raylib, libghostty-vt.To prove the include comes from pkg-config and not the nix cc wrapper, unset the nix C-include env and rebuild:
$ env -u NIX_CFLAGS_COMPILE -u CPATH -u C_INCLUDE_PATH dub build :terminal --force- Still builds → pkg-config (#3085) is supplying the include. ✅
fatal error: <header>: No such file or directory→ the binding is probably a plainlibraryinstead ofsourceLibrary, or the.devoutput / pkg-config is missing.
Adding a new C dependency, step by step
Make the library available (Nix).
- Flake library: add a flake input in
flake.nix(withfollows), then addinputs'.<input>.packages.<lib>and….<lib>.devto the devshellpackagesinnix/shells/default.nix. - nixpkgs library: add
pkgs.<lib>andpkgs.<lib>.dev. pkgs.pkg-configis already in the devshell.- Confirm:
pkg-config --cflags --libs <pcname>prints include + link flags.
- Flake library: add a flake input in
Create the binding sub-package
libs/<name>/:src/sparkles/<name>/c.c:c#pragma attribute(push, nogc, nothrow) // drop attrs the lib violates; never add `pure` if it has state #include <foo/foo.h> #pragma attribute(pop)dub.sdl:sdlname "<name>" dflags "-preview=in" "-preview=dip1000" libs "<linkname>" // must match the pkg-config package (modulo a `lib` prefix) configuration "library" { targetType "sourceLibrary" // REQUIRED — see the gotcha } configuration "unittest" { sourcePaths "../test-runner/src" importPaths "../test-runner/src" dependency "sparkles:base" path="../.." dflags "-checkaction=context" "-allinst" dflags "-defaultlib=libphobos2.so" "-L-fuse-ld=gold" platform="linux-dmd" dflags "--link-defaultlib-shared" "--linker=gold" platform="linux-ldc" lflags "--export-dynamic" platform="linux-ldc" }Register it in the root
dub.sdl: addsubPackage "libs/<name>"(keep the list sorted).
git addthe new files so the flake/devshell sees them.Use it from D:
import sparkles.<name>.c;and call the C symbols. Optionally add a thin RAII wrapper (structwith a destructor calling the C*_free,@disable this(this)) in a separate module — but keep it honest: the pragma already supplies the attributes, so no function-pointer casts.Verify:
dub test :<name>passes, the consumer (dub build :<app>) builds, and the unset-NIX_CFLAGS_COMPILEcheck above still finds the header.
When there is no pkg-config .pc file
pkg-config is the clean path. If upstream ships no .pc:
- Set
cImportPaths "<dir>"to the header directory. Unlike pkg-config, this applies at library-compile time too — but the directory must be known. Derive it in Nix and pass it via an env var consumed indub.sdl, e.g.dflags "-P-I$FOO_INCLUDE", exportingFOO_INCLUDEfrom the devshellshellHook. This is the pre-#3085 pattern; avoid it when a.pcexists. - For linking without pkg-config,
libs "<name>"resolves to-l<name>; ensure the-Ldirectory is on the linker search path (nixbuildInputshandle this).
ImportC limitations worth knowing
- Module name is the file stem. ImportC names the module after the file's base name, ignoring the package path:
sparkles/ghostty/c.cbecomes modulec. That is fine for one binding, but when a build links two ImportC bindings that both usec.c, their modules collide (module 'c' … conflicts with another module c). Give each shim a unique stem —libs/utf8proc/src/sparkles/utf8proc/utf8proc_c.c(moduleutf8proc_c), withpackage.ddoingpublic import sparkles.utf8proc.utf8proc_c;. The canonicalc.cis only safe while a binary pulls in at most one such binding. - Macros. ImportC exposes simple object-like
#define INT_CONST 5as manifest constants, but function-like macros andstatic inlinefunctions are not reliably importable. Re-declare the value in D (enum X = 1004;) or call a wrapper from the.c. (We hit this with libghostty'sGHOSTTY_MODE_FOCUS_EVENTand the device-attribute#defines — re-declared as Denums.) - Fortified
<wchar.h>is unparseable. A heavy system header that includes<wchar.h>(e.g.<notcurses/notcurses.h>) pulls glibc'sbits/wchar2.h, whose_FORTIFY_SOURCEinline wrappers use compiler builtins ImportC doesn't implement (__builtin_dynamic_object_size,__builtin_va_arg_pack). The nix cc wrapper forces_FORTIFY_SOURCE, so-P-D_FORTIFY_SOURCE=0can't override it. If the symbol you need has a primitive signature, skip the header and declare just its prototype in the shim — the symbol resolves from the.so. (notcurses'ncstrwidthisint(const char*, int*, int*), so the shim is one line, no#include.) - A shim that defines functions needs
cSourcePaths. Declaration-only shims (whose symbols live in the.so) are pulled in via-I+ import and link fine. But if the shim defines a wrapper (e.g. to hide a versioned/renamed symbol — ICU renamesubrk_open→ubrk_open_76via macros), the import-only path doesn't emit the body → undefined-reference at link. AddcSourcePaths "src/sparkles/<name>"so dub compiles the.cas a real source. Its module is then the bare stem (import icu_c;, notimport sparkles.icu.icu_c;). - Embedding Python (PyD), not ImportC. Calling Python from D in-process is done with PyD (the engine behind
$REPOS/dlang/autowrap), not an ImportC shim. Seelibs/base/tools/text-conformance/src/sparkles/text_conformance/layer10_python_wcwidth.dfor the working pattern (pin pyd to the untagged D-2.111-compat commit; read result lists element-wise;__import__inside comprehensions). conston parameters. ImportC may drop top-levelconston function parameters; match the generated signature (declare a callback's param non-constif the generated decl is non-const).- Sized structs. Many C APIs lead a struct with
size_t size;as a version tag — set it (s.size = S.sizeof;) before passing the struct out/in, as the upstream C examples do.
Checklist
- [ ]
.cshim wraps the#includein#pragma attribute(push, nogc, nothrow)(nopureif the library is stateful). - [ ] Binding package is
targetType "sourceLibrary". - [ ]
libs "<name>"matches the pkg-config package name (modulo alibprefix). - [ ] Devshell has
pkg-config+ the library + its.devoutput (and a flake input if upstream is a flake). - [ ] New files
git add-ed before building under Nix. - [ ] Registered in the root
dub.sdlsubPackagelist (sorted). - [ ] Verified the include comes from pkg-config (unset-
NIX_CFLAGS_COMPILEbuild). - [ ]
dub test :<name>passes.
See also
- AGENTS.md — environment/build/test,
git addnew files, layout. - Code Style — safety attributes (annotate non-templates, infer on templates).
- ImportC spec: https://dlang.org/spec/importc · pragma: https://dlang.org/spec/importc#pragma
- dub PRs: #2544 (cSourcePaths/ cImportPaths) · #2818 (cImportPaths DMD/LDC fix) · #3085 (pkg-config
--cflags).