What if Conan could provide Zig with library information - #20225
Conversation
New generator to consume Conan C/C++ dependencies from a Zig `build.zig`. Zig's build system has no format for describing a prebuilt C/C++ library, and does not propagate include or library paths to consumers through linkLibrary(), so there is nothing to emit into. The generator emits Zig source instead: `conan_deps.zig`, a comptime map of every dependency, and `conan_setup.zig`, helpers that a `build.zig` calls. Modelled on CMakeConfigDeps: one target per component keyed "pkg::component", each carrying only its own unmerged information, with explicit `requires` edges that the generated code walks - doing by hand what CMake's target system does natively. Requirement resolution reuses get_transitive_requires(), so requirement traits are honoured from the consumer's perspective rather than read off the dependency. The public API takes a *std.Build.Module rather than a *Step.Compile: in Zig 0.16 every call it makes exists only on Module, and a module is not necessarily an artifact's root, so the same call works for a test module. The C++ runtime is requested for any dependency not declaring `languages = "C"`, since assuming C++ for a C package costs nothing measurable while the reverse fails the link with undefined std:: symbols. It is skipped on the MSVC ABI, where the runtime comes from MSVC itself, and both `link_libc` and `link_libcpp` are only set when the consumer has not decided, so they can be overridden either side of the call. Covers build time only: making shared dependencies loadable at run time is left to Conan's `conanrun` environment or a deployer. Marked experimental - the generated Zig API is expected to change. Tests: 30 integration tests asserting on generated content with no Zig required, and 10 functional tests running a real `zig build` against ConanCenter packages, including variants where the dependencies are built by CMake rather than by `zig cc`. Zig 0.16.0 is registered in test/conftest.py and installed on Linux, macOS and Windows CI.
ZigDeps by exampleWritten with the help of an LLM for wording Four worked examples of a Zig project consuming Conan packages, using the experimental
Contents
How it works
Zig has no native format for describing a prebuilt C library, and does not propagate include Everything is keyed const conan = @import("conan_zig_deps/conan_setup.zig");
conan.linkDependencies(mod); // every direct dependency
conan.linkDependency(mod, "openssl::crypto"); // …or one specific target
conan.toolPath(b, "flex", "flex"); // a tool_requires executableThe helpers take a Once a module has been set up this way, 1. Zig using a C library — OpenSSLA Zig program that hashes a string with OpenSSL. No C sources of our own. Shows component-level linking and transitive resolution: OpenSSL's
[requires]
openssl/3.5.4
[generators]
ZigDeps
const std = @import("std");
// Zig imports the C headers directly. ZigDeps put OpenSSL's include directories on this
// module, so @cInclude resolves without any path being written here.
const ssl = @cImport({
@cInclude("openssl/evp.h");
@cInclude("openssl/crypto.h");
});
pub fn main() !void {
const msg = "conan + zig";
var digest: [ssl.EVP_MAX_MD_SIZE]u8 = undefined;
var len: c_uint = 0;
const ctx = ssl.EVP_MD_CTX_new() orelse return error.OpenSslFailed;
defer ssl.EVP_MD_CTX_free(ctx);
if (ssl.EVP_DigestInit_ex(ctx, ssl.EVP_sha256(), null) != 1) return error.OpenSslFailed;
if (ssl.EVP_DigestUpdate(ctx, msg, msg.len) != 1) return error.OpenSslFailed;
if (ssl.EVP_DigestFinal_ex(ctx, &digest, &len) != 1) return error.OpenSslFailed;
std.debug.print("{s}\n", .{std.mem.span(ssl.OpenSSL_version(ssl.OPENSSL_VERSION))});
std.debug.print("sha256(\"{s}\") = ", .{msg});
for (digest[0..len]) |b| std.debug.print("{x:0>2}", .{b});
std.debug.print("\n", .{});
}
const std = @import("std");
const conan = @import("conan_zig_deps/conan_setup.zig");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// A plain Zig module - no C or C++ sources of our own.
const mod = b.createModule(.{
.root_source_file = b.path("main.zig"),
.target = target,
.optimize = optimize,
});
// Only the "crypto" component is needed. Its own requires - openssl::crypto ->
// zlib::zlib - are followed automatically, so zlib is linked without naming it.
conan.linkDependency(mod, "openssl::crypto");
const exe = b.addExecutable(.{ .name = "digest", .root_module = mod });
b.installArtifact(exe);
const run = b.addRunArtifact(exe);
b.step("run", "Run the Zig program").dependOn(&run.step);
}conan install . -of . --build=missing
zig build runThe graph that gets walked, with nothing in The same thing, sharedconan install . -of . -o "openssl/*:shared=True" -o "zlib/*:shared=True" --build=missing
source ./conanrun.sh # <- required
zig build run
which is the expected, documented behaviour — not a bug. From a recipe, the equivalent is
Letting Zig compile your own C sourcesZig ships a C compiler, so a project that still has C of its own needs no separate toolchain // --- The same thing in C, compiled by Zig itself ---------------------------------
// Zig ships a C compiler, so a project with its own C sources needs no separate
// toolchain. ZigDeps is used identically either way.
const c_mod = b.createModule(.{ .target = target, .optimize = optimize });
c_mod.addCSourceFile(.{ .file = b.path("digest.c"), .flags = &.{"-std=c11"} });
conan.linkDependency(c_mod, "openssl::crypto");
const c_exe = b.addExecutable(.{ .name = "digest-c", .root_module = c_mod });
b.installArtifact(c_exe);
const run_c = b.addRunArtifact(c_exe);
b.step("run-c", "Build the C version with Zig and run it").dependOn(&run_c.step);zig build run-cNote the difference from the Zig module: a C module needs no 2. Zig using a C++ library — snappyZig can only This is also the sharpest demonstration of what
Note also the two targets snappy produces:
[requires]
snappy/1.1.10
[generators]
ZigDeps
const std = @import("std");
// snappy is written in C++, but ships snappy-c.h - a real C API maintained by the project
// itself. That is what makes it usable from Zig directly: Zig can @cImport C headers, but
// never C++ ones, so a C++ library is only reachable when it exposes a C surface like this.
//
// Nothing below is C++, yet the C++ standard library is still required at link time,
// because the *implementation* behind this C API is C++. ZigDeps detects that and asks for
// the runtime; without it the link fails with undefined std:: symbols.
const snappy = @cImport({
@cInclude("snappy-c.h");
});
pub fn main() !void {
const input = "conan conan conan zig zig zig zig";
var compressed: [256]u8 = undefined;
var compressed_len: usize = compressed.len;
if (snappy.snappy_compress(input, input.len, &compressed, &compressed_len) != snappy.SNAPPY_OK)
return error.CompressFailed;
var restored: [256]u8 = undefined;
var restored_len: usize = restored.len;
if (snappy.snappy_uncompress(&compressed, compressed_len, &restored, &restored_len) != snappy.SNAPPY_OK)
return error.UncompressFailed;
std.debug.print("compressed {d} -> {d} bytes\n", .{ input.len, compressed_len });
std.debug.print("round trip ok: {}\n", .{std.mem.eql(u8, input, restored[0..restored_len])});
}
const std = @import("std");
const conan = @import("conan_zig_deps/conan_setup.zig");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// A plain Zig module. No C++ sources, and no shim: snappy provides the C API itself.
const mod = b.createModule(.{
.root_source_file = b.path("main.zig"),
.target = target,
.optimize = optimize,
});
// link_libcpp is never set here. ZigDeps knows snappy is a C++ package and asks for the
// C++ runtime itself - a Zig binary would otherwise not link it at all.
conan.linkDependencies(mod);
const exe = b.addExecutable(.{ .name = "roundtrip", .root_module = mod });
b.installArtifact(exe);
const run = b.addRunArtifact(exe);
b.step("run", "Run the example").dependOn(&run.step);
}conan install . -of . --build=missing
zig build runAdd When the library has no C APIMost C++ libraries do not ship one. Prefer a library with an official C API where one exists; a hand-written shim is code you How C++ is detectedA dependency's That default is deliberate, because the two mistakes are not equally bad. Assuming C++ for a On the MSVC ABI the runtime is never requested: there it comes from MSVC itself, pulled in by Overriding the runtime linkage
mod.link_libcpp = false; // never link the C++ runtime for this module
mod.link_libcpp = true; // always link it, e.g. for a header-only C++ package
conan.linkDependencies(mod);This is an escape hatch rather than something you normally need. A package whose Troubleshooting: headers that rely on transitive includesZig bundles its own libc++, whose headers include slightly less than Apple's or GNU's. A
[requires]
fmt/12.0.0If you are pinned to a version that still has the problem, force the include from the mod.addCSourceFile(.{ .file = b.path("shim.cpp"),
.flags = &.{ "-std=c++17", "-include", "cstdlib" } });3. Zig using a build tool — flexShows
[tool_requires]
flex/2.6.4
[generators]
ZigDeps
%option noyywrap nounput noinput
%{
int words = 0, numbers = 0;
%}
%%
[0-9]+ { numbers++; }
[a-zA-Z]+ { words++; }
.|\n { /* skip */ }
%%
int count_words(void) { return words; }
int count_numbers(void) { return numbers; }
const std = @import("std");
// The lexer C source does not exist in the repository: flex generates it during the build,
// and Zig compiles it into this binary. Only the hand-written header is imported.
const lexer = @cImport({
@cInclude("lexer.h");
});
pub fn main() !void {
_ = lexer.yylex(); // reads stdin
std.debug.print("words={d} numbers={d}\n",
.{ lexer.count_words(), lexer.count_numbers() });
}
const std = @import("std");
const conan = @import("conan_zig_deps/conan_setup.zig");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// flex comes from [tool_requires], so it lives in the *build* context: there is nothing
// to link, only a program to run.
const flex = b.addSystemCommand(&.{ conan.toolPath(b, "flex", "flex"), "-o" });
const lexer_c = flex.addOutputFileArg("counter.c");
flex.addFileArg(b.path("counter.l"));
const mod = b.createModule(.{
.root_source_file = b.path("main.zig"),
.target = target,
.optimize = optimize,
});
mod.addCSourceFile(.{ .file = lexer_c, .flags = &.{} });
mod.addIncludePath(b.path("."));
mod.link_libc = true;
const exe = b.addExecutable(.{ .name = "counter", .root_module = mod });
b.installArtifact(exe);
const run = b.addRunArtifact(exe);
run.setStdIn(.{ .bytes = "conan 2 zig 16 rules 42\n" });
b.step("run", "Generate the lexer with flex, then build and run it").dependOn(&run.step);
}conan install . -of . --build=missing
zig build run
|
| Limitation | Why |
|---|---|
Zig cannot @cImport C++ headers |
A Zig-only property. C++ dependencies need a C surface: the library's own, or a shim — see example 2. |
On Windows the consumer must target the msvc ABI |
Conan's Windows binaries are MSVC; Zig defaults to MinGW. See above. |
A dependency's cflags / cxxflags / link flags are not applied |
Zig has no module-level flag injection — Module.addCSourceFile only applies flags to files added through it. They are emitted in conan_deps.zig and Conan warns when a dependency declares any, so pass them yourself. |
| No runtime discovery (no rpaths, no copied DLLs) | Deliberate: conanrun and deployers already solve this. See example 1. |
No set_property / target-name customisation |
Target names are fixed as pkg::component. |
| Paths are absolute | Output is not relocatable after a deployer. |
| Header-only C++ packages that clear settings look like C | Set link_libcpp yourself. See example 2. |
Verification
Every example above was built and run before publishing. Nothing here is illustrative-only,
and the code shown is the code that was compiled.
| Toolchain | |
|---|---|
| Platform | macOS 26, arm64 (Apple Silicon) |
| Zig | 0.16.0 |
| Packages | openssl/3.5.4, zlib/1.3.1, snappy/1.1.10, flex/2.6.4 (+ m4/1.4.19), cmocka/1.1.7 |
Examples 1, 2 and 4 were each built and run in both static and shared configurations.
Example 3 has no link variant, being a build tool
| if dep.package_type is not PackageType.APP] | ||
| direct_targets = [t for t in direct_targets if t in targets] | ||
|
|
||
| if flag_deps: |
There was a problem hiding this comment.
Should we warn when the profile uses compiler=gcc or libcxx!=libc++ and deps get built against libstdc++, but Zig links its own bundled libc++?
Changelog: Feature: Add experiemntal
ZigDepssupport to allow zig code to compile using C/C++ librariesDocs: TODO
Superset of #19626, using it as a base and Calude as insight for of Zig expertise
$runslowtests