C

The C and C++ rules support two levels of integration. ccLibrary() and ccBinary() build a task graph directly from declared sources, while cmakeProject() (//rules/c/cmake) imports an existing CMake/Ninja project and discovers separately selectable native targets and CTest cases. Both paths use declared compiler and linker toolchains and expose their artifacts to downstream native targets — a ccBinary({deps: [...]}) takes another ccLibrary() call's result directly, not a label reference.

Set up a compiler

Import the C rules in imp.workspace.js. Raw C targets prefer the default Zig toolchain and otherwise fall back to GCC; CMake targets build with GCC only (see //rules/c/cmake's own docs for the zig gap).

import "//rules/c";
import "//rules/c/cmake";
import "//rules/workflows/package";
import "//rules/workflows/test";

Override a rule default only when needed by declaring a replacement with { default: true }, or pass a toolchain handle explicitly on one target — gccGraphToolchain(version)/zigGraphToolchain(version) (//rules/c/gcc, //rules/c/zig), not the legacy per-rule toolchain classes.

This repository's workspace imports //rules/imp/mode. Its default profile builds raw C/C++ with -O0 -g and configures CMake with CMAKE_BUILD_TYPE=Debug; --profile release uses -O2 -DNDEBUG and CMAKE_BUILD_TYPE=Release. Target copts and cmakeArgs are appended after those defaults and can override them for one target.

Select a workspace lockfile

Each managed C toolchain (Zig, GCC, CMake, mold, NASM) ships a lockfile pinning the download URL, size, and SHA-256 of every release artifact it knows. To pin a version the shipped lockfile does not know, give the toolchain the address of a lockfile this workspace owns; the shipped lockfile stays the default.

import { gccToolchain } from "//rules/c/gcc";

export const gcc = gccToolchain("2024.05-1", {
	default: true,
	lockfile: "//locks/gcc.lock",
});

The toolchain handle is also the lockfile generation root, so write the file with:

imp goal gen-lockfiles //:gcc

The generation root writes to the address the toolchain declares, so the address is given one time only. Downloads stay verified: an address with no file, or a lockfile with no entry for the selected version and platform, makes the acquire fail and points at imp goal gen-lockfiles. gccToolchain also takes an os-keyed map (lockfile: { linux: "//locks/gcc.lock", windows: "//locks/gcc-windows.lock" }) to pin each platform, matching its version argument.

NASM is the assembler an MSVC-driven cmakeProject() uses for projects that enable_language(ASM_NASM) (e.g. BoringSSL). Declare nasmToolchain from //rules/c/msvc in imp.workspace.js; msvcToolchain() picks up the default.

import { nasmToolchain } from "//rules/c/msvc";

export const nasm = nasmToolchain("3.02", {
	default: true,
	lockfile: "//locks/nasm.lock",
});
imp goal gen-lockfiles //:nasm

Declare raw targets

import { ccLibrary, ccBinary } from "//rules/c";

export const math = ccLibrary({
    srcs: ["math.c"],
    hdrs: ["math.h"],
});

export const calculator = ccBinary({
    srcs: ["main.c"],
    deps: [math],
    copts: ["-Wall", "-Wextra"],
});

Source and header globs are evaluated relative to path, which defaults to the declaring BUILD.js directory. A library produces a static archive; a binary links an executable. deps takes other ccLibrary() call results directly (handle-passing), which the target's own transitiveArchives/transitiveSharedLibs/transitiveIncludeDirs/transitiveLinkopts fold in automatically — not a loose filesystem path or label reference. A discovered CMake target needs wrapping with cmakeLibraryDep() (//rules/c/cmake) first — see its own docs. Use linkopts for options that belong only at this target's own link step (not propagated to anything depending on it — use a dep's transitiveLinkopts for flags a consumer needs, e.g. a shared library's own -L/-l dependencies).

The output filename stem defaults to the directory slug. If two targets in one directory need the same kind of artifact name, set outputName on each declaration to give them distinct stems. This does not change the directory slug used for build namespaces or the target name used by imp package:

export const client = ccBinary({
    srcs: ["client.c"],
    outputName: "client",
});
export const server = ccBinary({
    srcs: ["server.c"],
    outputName: "server",
});

outputName is a portable filename stem. Do not include .a, .so, .dll or .exe; the rule adds the platform-specific suffix and the lib prefix for Unix shared libraries.

Generated sources

generatedSrcs takes a codegen() result from //rules/imp/codegen and stages every file it declares into the compile sandbox — a .c/.cc/.cpp is compiled like any globbed source, a .h is mounted and resolves against the -I<path> already on every compile line. codegen() is not C-specific; it declares files a command writes into the graph, for any ecosystem. Exclude a generated path from any overlapping srcs/hdrs glob so one file has one owner:

import { ccBinary } from "//rules/c";
import { codegen } from "//rules/imp/codegen";
import { nativeTool } from "//rules/imp/native-tool";
import { files } from "imp:core";

const proto = codegen({
    display: "generate protocol bindings",
    tools: { protoc: nativeTool("protoc") },
    inputs: { schema: files({ root: "app", include: ["wire.proto"] }) },
    outputPaths: ["app/generated/wire.c", "app/generated/wire.h"],
    argv: (exec, { protoc }) => [
        exec.tool(protoc, "protoc"),
        "--c_out=app/generated",
        "app/wire.proto",
    ],
});

export const app = ccBinary({
    path: "app",
    srcs: ["main.c"],
    generatedSrcs: [proto],
});

outputPaths are workspace-relative, and a nested path needs no mkdir — the executor creates the parent directory of each declared output before the program starts. The build fails if a staged artifact's real path differs from the path its entry declared. The older explicit form, generatedSrcs: [{ artifact, path }] with path relative to the package, also works.

To write generated files into the workspace instead of into the graph — for committed, drift-gated codegen — use generatedFiles() from //rules/imp/generate and imp generate.

Static archives and shared libraries

ccLibrary() produces a static .a archive by default and reports it as transitiveArchives. With shared: true it produces a lib<name>.so (<name>.dll on Windows, which uses no lib prefix) and reports it as transitiveSharedLibs instead. The two buckets stay separate because the two kinds of file need different handling: an archive is fed both to ar and to the linker, while a shared library is only ever fed to the linker. A consumer folds in both automatically, so either kind of dep links without the caller doing anything.

export const plugin = ccLibrary({
    srcs: ["plugin.c"],
    shared: true,
});

export const host = ccBinary({
    srcs: ["main.c"],
    deps: [plugin],
});

A discovered CMake target must state which kind it is — cmakeLibraryDep(project, "mylib", { shared: true }), see //rules/c/cmake's own docs.

The filename is not decoration. A shared library is linked with -Wl,-soname,lib<name>.so, so a consumer records that bare name in its own DT_NEEDED entry and the loader can answer it from a search path. Without a soname the linker records the library's build path instead, and a DT_NEEDED holding a slash makes the loader skip its search paths entirely. imp package publishes a shared library under this same filename rather than under the target name, for the same reason — so a packaged library and a packaged consumer in one directory resolve against each other.

A binary's product carries its shared libraries

A binary that links a workspace-built shared library needs that library beside it at run time, so its product is a directory rather than a single file:

dist/<package>/<target>/
    <output-name>          the executable, linked with -Wl,-rpath,$ORIGIN
    lib<name>.so           every transitive shared library, under its soname

The executable uses outputName when one is supplied, or the directory slug otherwise. The bundle directory itself remains directory-derived.

$ORIGIN is expanded by the loader to the directory holding the executable, which is where the libraries are, so the binary runs with LD_LIBRARY_PATH unset — under imp run, under imp test, from dist/ after imp package, and when invoked directly. Both halves are needed: staging without the rpath gives the loader no reason to look beside the executable, and the rpath without staging points at a directory holding no library.

A binary with no shared dependency keeps the single-file product it has always had at dist/<package>/<target>. Nothing has to travel beside it, so nothing changes.

Windows is unverified. A .dll beside the executable is found by the default search order and there is no rpath concept, so the copy alone should suffice there; the rpath argument is omitted on Windows and by MSVC.

Running and testing a binary

ccBinary() exposes a [RUN] root, so imp run //pkg:target launches the built executable (a bundled one through its own product directory). ccTest() takes every ccBinary() option and adds a [TEST] root that runs the executable and reports its exit code as one test unit — the same granularity //rules/c/cmake reports for a CTest entry. The assertion belongs inside main():

export const adds_test = ccTest({
    srcs: ["adds_test.c"],   // int main(void) { return add(2, 3) == 5 ? 0 : 1; }
    deps: [mathlib],
});

The default GCC toolchain (//rules/c/gcc) is a Bootlin external toolchain whose compiler wrapper rejects any -I/-isystem/-L flag pointing under /usr/include or /usr/lib ("unsafe header/library path used in cross-compilation"), which blocks linking against host system packages. Pass unsafeSystemPaths: true to bypass that guard for one target — same toolchain sysroot, just without the check (no-op on the Zig toolchain, which has no such guard):

export const webview = ccLibrary({
    srcs: ["webview.c"],
    copts: ["-isystem", "/usr/include/webkitgtk-4.1"],
    unsafeSystemPaths: true,
});
imp build //native/calculator:calculator
imp package //native/calculator:calculator

ccLibrary()/ccBinary() expose [BUILD]/[PACKAGE] directly on their returned object — no separate export-wrapping needed, unlike a discovered CMake target (see //rules/c/cmake's own docs).

For bespoke builds outside this model entirely, declare a label in the BUILD file and attach build(), test(), or packageGoal() handlers directly — the legacy, pre-graph-native escape hatch, still supported for builds that don't fit ccLibrary()/ccBinary()'s shape.

Generate declarations

The C build generator scans unowned CMake and C/C++ sources and writes appropriate declarations. Enable it explicitly:

export const cConfig = {
    buildGenerate: true,
};

Then run imp goal generate-build. Generation is opt-in so repositories with custom ownership or mixed build layouts are not rewritten unexpectedly.

Configuration

OptionTypeDefaultRequired
buildGenerateboolfalseno

Example

export const cConfig = {
    buildGenerate: false,
};

Targets

ccLibrary()

ccLibrary(opts = {})

Declare a graph-native raw C/C++ library. deps takes other ccLibrary()/cmake-project-target call results directly (handle-passing, not label references) — see this module's own docstring for the transitiveArchives contract shared with rules/c/cmake's graph-native targets.

ParameterTypeDescription
[opts]object
[opts.path]stringWorkspace-relative directory. Defaults to the calling BUILD.js's own directory (".").
[opts.srcs]string[]Source glob, default DEFAULT_CPP_SRCS.
[opts.hdrs]string[]Header glob (input-only, not individually inspected), default DEFAULT_CPP_HDRS.
[opts.deps=[]]ArrayOther ccLibrary()/cmake-target results this library links against.
[opts.toolchain]objectgccGraphToolchain()/zigGraphToolchain() result, or the workspace default.
[opts.copts=[]]string[]Extra compiler flags.
[opts.outputName]stringPortable artifact filename stem. Defaults to the directory-derived slug; rules add the platform-specific library suffix.
[opts.unsafeSystemPaths=false]booleanBypass Bootlin's toolchain-wrapper unsafe-path guard (which rejects -I/-isystem/-L flags under /usr/include or /usr/lib) so this target can link against host system packages (e.g. libwebkit2gtk-4.1). No-op on a zig toolchain, which has no such guard.
[opts.shared=false]booleanBuild a dynamically-loadable shared object (-shared, platform-correct extension: .dll on Windows, .so elsewhere) instead of a static .a archive. The result is reported as transitiveSharedLibs rather than transitiveArchives, so a dependent ccLibrary()/ccBinary() links against it but never tries to ar it in. A consumer's product carries the library beside its own executable and is linked with an $ORIGIN rpath, so it also loads at run time — see ccBinary().

Returns: object Frozen {[BUILD], archive, transitiveArchives, transitiveSharedLibs, transitiveIncludeDirs, transitiveHdrs, transitiveLinkopts, [PACKAGE]}.

ccBinary()

ccBinary(opts = {})

Declare a graph-native raw C/C++ binary. Same deps/toolchain contract as ccLibrary() — see this module's own docstring.

ParameterTypeDescription
[opts]object
[opts.path]stringWorkspace-relative directory. Defaults to the calling BUILD.js's own directory (".").
[opts.srcs]string[]Source glob, default DEFAULT_CPP_SRCS.
[opts.hdrs]string[]Header glob, default DEFAULT_CPP_HDRS.
[opts.deps=[]]ArrayccLibrary()/cmake-target results this binary links against.
[opts.toolchain]objectgccGraphToolchain()/zigGraphToolchain() result, or the workspace default.
[opts.copts=[]]string[]Extra compiler flags.
[opts.linkopts=[]]string[]Extra linker flags for this binary's own link step (not propagated to anything that might depend on it — deps' own transitiveLinkopts are folded in automatically instead).
[opts.outputName]stringPortable executable filename stem. Defaults to the directory-derived slug; .exe is added on Windows.
[opts.unsafeSystemPaths=false]booleanBypass Bootlin's toolchain-wrapper unsafe-path guard (which rejects -I/-isystem/-L flags under /usr/include or /usr/lib) so this target can link against host system packages (e.g. libwebkit2gtk-4.1). No-op on a zig toolchain, which has no such guard.

Returns: object Frozen {[BUILD], [PACKAGE], [RUN]}. The product is a single executable file, or a directory holding the executable plus every transitive shared library when there is one (see this module's own docstring).

ccTest()

ccTest(opts = {})

Declare a graph-native raw C/C++ test binary: a ccBinary() whose exit code is the test result. Same deps/toolchain contract as ccBinary() — see this module's own docstring. The binary is run with no arguments and nothing else mounted, so an assertion belongs inside main() (return actual == expected ? 0 : 1). That is the same granularity //rules/c/cmake reports for a CTest entry and //rules/rust for a test binary: one unit per executable, not per case.

ParameterTypeDescription
[opts]objectEvery ccBinary() option, unchanged.
[opts.outputName]stringPortable executable filename stem. Defaults to the directory-derived slug; .exe is added on Windows.

Returns: object Frozen {[BUILD], [RUN], [TEST]}.