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:
// Preload shared system types outside the D attribute scope.
#include <stdint.h>
// 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)
#define ghostty_mode_new sparkles_ghostty_mode_new_impl
#include <ghostty/vt.h>
#undef ghostty_mode_new
GhosttyMode ghostty_mode_new(uint16_t value, bool ansi);
#pragma attribute(pop)- The file name becomes the D module.
c.c→sparkles.ghostty.c. Consumers writeimport sparkles.ghostty.c;and externally linked declarations in the header are callable D symbols (allextern(C)automatically). - ImportC parses this declaration module on import. Function bodies that need linking must also be explicitly compiled as sources, as below.
#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.- Header-only
static inlinefunctions need a shared entry point. DMD cannot emit a call from a D module to a C function with internal linkage. Ghostty'sGHOSTTY_MODE_*macros callghostty_mode_new, so even usingGHOSTTY_MODE_SYNC_OUTPUTcan trigger this error. Rename the upstream helper during inclusion, then expose an external wrapper under the original name. The mode macros expand after the rename is undone and call the wrapper; direct D callers use that same entry point. Keep the upstream body rather than duplicating its mode encoding or hardcoding individual constants. - Keep the shim to the wrapped
#include(s) and necessary C linkage bridges. Put D-side conveniences (RAII wrappers, helpers) in separate.dmodules.
libs/ghostty/src/sparkles/ghostty/ghostty_modes.c supplies the constructor body without making the imported declaration module a root source (and changing its module name):
#include "c.c"
#pragma attribute(push, nogc, nothrow)
GhosttyMode ghostty_mode_new(uint16_t value, bool ansi) {
return sparkles_ghostty_mode_new_impl(value, ansi);
}
#pragma attribute(pop)2. The dub.sdl
libs/ghostty/dub.sdl (trimmed):
name "ghostty"
dflags "-preview=in" "-preview=dip1000"
libs "ghostty-vt"
sourceFiles "src/sparkles/ghostty/ghostty_modes.c"
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.sourceFiles "src/sparkles/ghostty/ghostty_modes.c"— explicitly compiles the wrapper body without compiling the declaration-onlyc.cas a root. ThesourceLibrarytarget propagates it to consumers; keeping it undersrcalso includes it in Nix's filtered source closure. A plain import ofsparkles.ghostty.conly supplies declarations.
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. - Shared system types must use the same attributes. ImportC merges C structs across imported translation units, including function-pointer field types. Wrapping system headers in
#pragma attribute(push, nogc, nothrow)in only one import makes those types incompatible (Darwin's pthread cleanup callback is one example). Ghostty's shim,tree-sitter/tree_sitter_c.c,raylib-text/shaping_c.c, andshaping_api.hall include<stdint.h>outside the attribute scope. On Darwin that header transitively supplies the pthread types, so later attributed includes reuse their original unannotated definitions. Validate consumers that combine bindings: terminal alone does not exercise hue's tree-sitter and shaping combination. - A same-stem
.hshadows.con import. ImportC searches.i, then.h, then.cafter the D extensions.import sparkles.raylib_text.shaping_apitherefore importsshaping_api.hdirectly: put the API's attributes in that header, after its system includes, rather than in a same-stem.cwrapper.
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).