ADR-324: A Package Links a C Library by Name, as Data the Compiler Acts On — There Is No Build Script, and a Package Brings No C Sources Yet

Status: Accepted Date: October 6, 2026 (#483; D2, D3, D6 revised the same day) Answers: what a package does that needs a C library, after ADR-321 D15 left no way for a package’s code to run while a program is built; Part III 13.4’s build.nika, which described one Related: ADR-302 (a call into C, and what crosses); ADR-321 D9, D14-D15 (no foreign code runs while a program is built); ADR-287 D14 (build-time code touches nothing); ADR-310 (what a build may read); ADR-290 (a Rust crate is described before it is called)

1. Question

How does a package whose Nikaia code calls a C library get that library linked, in every program that uses the package, without the program’s author doing anything and without anything of the package running while it is built?

A package such as a SQLite driver declares extern functions and offers a Nikaia interface over them. The functions live in a library the linker has to find. Today nothing says which: a program passes -l sqlite3 to rustc through RUSTFLAGS by hand.

2. How others do it

Tool How a C library is linked or built Does the package’s code run while building?
Cargo build.rs calls pkg-config, cc, bindgen; system-deps reads a table but still needs a short build.rs yes, with every right, for every dependency
Zig build.zig is Zig code that builds a graph (linkSystemLibrary, addCSourceFiles) a runner executes; Zig compiles C itself (zig cc) yes: build.zig may do anything, and every dependency’s runs
Go directives in the source (#cgo pkg-config: sqlite3, #cgo LDFLAGS: -lsqlite3); go build compiles the package’s C files; go generate is never run by the build no; and after CVE-2018-6574 (-fplugin in #cgo CFLAGS) only flags on an allow list pass, pkg-config’s output included
SwiftPM Package.swift is Swift; .systemLibrary(pkgConfig:) yes, in the operating system’s sandbox on macOS

From memory, not checked against a version. What transfers: Go’s split - the package says what, the build tool decides how - and its allow list of flags. What does not: a build script in any language, which Zig shows is as powerful as Cargo’s however its own language reads.

3. Options, weighed

Option Why it lost
build.nika, a program run before the build with a std::build API Cargo’s build.rs again: a package’s code running with every right while a program is built, which ADR-321 D15 rules out
A script that only builds a graph (Zig) still a program that may do anything; declaring the graph needs no program
Flags the package writes freely a flag can load code into the compiler or linker (-fplugin, -Wl,-plugin, -specs=)
Prebuilt binaries per platform in the package machine code nobody reviews, shipped into every program that uses the package
C sources in the package, compiled by the build see D5

4. Decision

D1 — There is no build script.

No build.nika, no std::build, no program of the package run before or during a build. What a build needs from a package is data.

D2 — An extern block names its library, and the manifest says how it is found; both are required.

extern(library: "sqlite3") {
    opaque type sqlite3 released by sqlite3_close
    fn sqlite3_open(path: ref Array[u8], out: ref mut sqlite3?) -> i32
}
[library.sqlite3]
pkg-config = "sqlite3"

The block says what its functions need, beside them; the entry says where it comes from, and pkg-config is required in it, written out even where it repeats the name: an entry is written rarely and copied often, and a written value is one nobody has to know the default of. A block naming a library the manifest has no entry for, or an entry no block names, is NK1226. A program that depends on the package links the libraries it names; its author writes nothing for it. A block without library links nothing of its own.

Rejected: the name in the block alone, found by pkg-config with -l as a fallback - a fallback that silently loses pkg-config’s search paths on a machine without it, and a default a reader has to know; the manifest alone, which separates a function from the library it lives in.

D3 — The compiler finds a library through pkg-config, and only through it.

The compiler runs the machine’s pkg-config with arguments it forms from the entry, never with arguments the package writes. A library pkg-config does not find, or a machine without pkg-config, refuses the build with NK1224, naming the package, the library and the pkg-config name.

D4 — Only flags on a fixed list reach the linker.

From pkg-config --libs: -l, -L, -pthread, and on macOS -framework and -F. Any other flag is refused with NK1225, naming it and the package whose library returned it. Nothing is compiled from C (D5), so no compiler flag is taken at all.

D5 — A package brings no C sources, for now.

Compiling C runs nothing of the package where the build chooses the flags: the preprocessor has no way to start a program. But it reads: #include with an absolute path reads any file the compiler may read, and shows it in its error message; C23’s #embed puts it into the object file; a computed #include MACRO cannot be found by reading the source first. No switch of GCC or Clang forbids it, so a build would read past ADR-310. Where the machine that builds is not the one that runs - a CI job with secrets - that is new reach. And a C compiler is no security boundary against a hostile source. This is reopened when what a C compilation reads can be bounded.

D6 — extern is written without a convention: extern "C" is refused.

Changes ADR-302 D1 and ADR-284 D4’s spelling.

extern { fn strlen(s: ref Array[u8]) -> usize }          // calling C
extern(library: "sqlite3") { … }                         // calling C, library named
pub extern fn greet(name: ref String) -> String { … }    // called from C
pub extern struct Point { x: f64, y: f64 }               // C's layout

The C convention is the only one on every target (ADR-284 D22), so a subject that can hold one value says nothing. What follows extern in parentheses is options, as a dsl block’s build-time arguments and a comptime’s bounds are. extern "C" is NK1227, and the help writes extern. Should a second convention ever be needed, it comes as a subject before a ;, extern(X; library: …), and an extern without one stays C.

Rejected: extern "C" library "…", which reserves library everywhere (ADR-298 D1: no word is contextual); extern(foreign::Abi::C; …), an enum of one variant written at every block; extern.C, which is no path this language writes.

5. Consequences

6. What this does not decide

This site is open source. Improve this page.