- Event
- Embedding a C Library in Swift Package Manager
Article
Embedding a C Library in Swift Package Manager
How to make a C library consumable from Swift Package Manager — using libevent and Event as the worked example, with the module-map and product-re-export patterns extracted for your own ports.
Overview
If you’ve tried to bring a non-trivial C library — libevent, libssh, libuv, OpenSSL, libtor — into a Swift Package Manager target, you’ve hit the same questions every time:
Where do the headers go?
How do you write a module.modulemap so Swift sees the C symbols?
How do you let other SwiftPM packages link against the C library through yours, without duplicating the build?
What about C++ consumers that can’t parse the headers as a Clang module?
This article documents the pattern this package uses for libevent, generalised for your own ports. The pattern is also the proof for swift-tor, which depends on libevent (from this package) alongside libcrypto / libssl (from swift-openssl) to build a Swift-native Tor daemon — without bundling its own libevent build.
The shape of the problem
A C library you want to wrap typically has this layout in its upstream source tree:
libfoo/
├── include/
│ ├── foo.h
│ └── foo/
│ ├── core.h
│ ├── ext.h
│ └── ...
├── src/
│ ├── core.c
│ ├── ext.c
│ └── ...
└── configure.ac // or CMakeLists.txt — autotools / cmake build
SwiftPM doesn’t run configure or cmake. To consume the library from Swift, you need to:
Place the headers somewhere SwiftPM expects (Sources/<target>/include/).
Place the source files somewhere SwiftPM compiles (Sources/<target>/).
Write a Package.swift target declaration that tells SwiftPM how to compile the sources and what flags / defines to pass.
(For non-trivial libraries) write a manual module.modulemap that tells Clang which headers form the public module surface.
Step 1 — Vendoring the upstream sources
The cleanest pattern is to copy or git subtree the upstream include/ and src/ directories into your SwiftPM target:
swift-libfoo/
├── Package.swift
└── Sources/
└── libfoo/ // SwiftPM target name
├── include/
│ ├── foo.h
│ ├── foo/
│ │ ├── core.h
│ │ └── ext.h
│ └── module.modulemap // ← we'll write this
└── src/
├── core.c
└── ext.c
This package uses a git subtree extraction (driven by subtree.yaml) so that running the extraction script pulls fresh upstream sources from the libevent repo into Sources/libevent/. See subtree.yaml and the Vendor/AGENTS.md notes for the exact configuration.
For a one-off vendor, plain cp -R works.
Important
Do not edit the vendored files in place. Any patches you need go in a separate “manually maintained” file list (this package keeps the list in Vendor/AGENTS.md) so re-extraction doesn’t silently overwrite your changes. Forgetting this rule is the single most common way to lose patches across upstream-sync cycles.
Step 2 — Package.swift for the C target
.target(
name: "libfoo",
exclude: [
// Sources you don't want compiled (e.g., platform-specific
// implementations that conflict with the host's libc):
"src/arc4random.c",
],
cSettings: [
// Pass any defines the upstream build system would have set:
.define("_GNU_SOURCE", .when(platforms: [.linux])),
.define("HAVE_CONFIG_H"),
]
)
Two non-obvious points:
exclude: lets you skip files that conflict with the host platform — for example, libevent ships its own arc4random.c that collides with glibc 2.36+’s arc4random_buf definition. This package excludes that file and lets the bundled getrandom() fallback handle randomness on Linux.
cSettings .define declarations stand in for what ./configure would have written into config.h. For libevent, this means event-config.h is shipped as a hand-maintained file (see Sources/libevent/include/event2/event-config.h) rather than being generated at build time. The trade-off: you lose autoconf’s per-host detection, and you have to update event-config.h when the host environment changes (e.g., the recent glibc 2.36 arc4random handling).
Step 3 — The module map
Use a hand-written shim header listed in the modulemap; do NOT use umbrella ".". When SwiftPM sees a module.modulemap inside Sources/<target>/include/, it uses your map and skips its automatic generation. The recommended pattern for a non-trivial C library is:
module libfoo {
requires !cplusplus
header "swift-shim.h"
export *
}
…paired with Sources/<target>/include/swift-shim.h:
#ifndef LIBFOO_SWIFT_SHIM_H
#define LIBFOO_SWIFT_SHIM_H
#include "foo.h"
#include "foo/core.h"
#include "foo/ext.h"
/* …every public header you want Swift consumers to see… */
#endif
What each line does:
requires !cplusplus — Clang skips this module when the consumer is C++ (i.e. __cplusplus is defined). The decisive reason is downstream Swift C++ interop: when a consumer compiles C++ with -cxx-interoperability-mode=default, Clang is invoked with -fmodules forced on, and requires !cplusplus keeps libfoo from being loaded as a module in C++ TUs. The module is still available to Swift and plain-C consumers.
header "swift-shim.h" — claims a single shim header for the module. The shim textually #includes every public C header you want Swift to see; transitive includes propagate symbols into Swift’s view. Only the shim itself is owned by the module — none of the underlying foo/*.h headers are claimed. That’s the critical property: a downstream C++ consumer doing #include <foo/core.h> via the -I path resolves to a header Clang considers unclaimed, so it textually includes it instead of attempting a module load that would fail the requires !cplusplus clause.
export * — re-exports every imported symbol so Swift consumers don’t need to qualify with submodule prefixes.
Warning
Do not use umbrella ".". It looks tempting because it auto-includes every header in the directory, but it has a fatal interaction with Swift C++ interop: umbrella "." claims every foo/*.h as part of the libfoo module, so a C++ TU doing #include <foo/core.h> triggers Clang to load the module, evaluate requires !cplusplus, fail, and emit error: module 'libfoo' is incompatible with feature 'cplusplus' instead of falling through to a textual include. This is exactly the regression that broke swift-bitcoin in swift-event 0.2.0; the shim-header pattern was introduced to fix it. The Examples/LinuxConsumerProbe/ package in this repo (built by every CI run) is the regression coverage that catches it.
If your library’s public API is reachable through one umbrella header AND no downstream consumer uses Swift C++ interop, you can use the simpler form:
module libfoo {
requires !cplusplus
umbrella header "foo.h"
export *
}
…but umbrella header "foo.h" claims every header transitively reachable from foo.h, which is the same trap as umbrella "." for C++ consumers. The shim-header form is the safe default for a library you expect anyone to consume from a C++-interop Swift project.
Step 4 — Exposing the C target as a SwiftPM product
To let other Swift packages depend on your C library directly (not through your Swift wrapper), declare it as a product in Package.swift:
let package = Package(
name: "swift-libfoo",
products: [
.library(name: "libfoo", targets: ["libfoo"]), // ← raw C bindings
.library(name: "Foo", targets: ["Foo"]), // ← idiomatic Swift API
],
targets: [
.target(name: "libfoo", /* ... as above ... */),
.target(name: "Foo", dependencies: ["libfoo"]),
]
)
A downstream package can then depend on either:
// Downstream Package.swift
dependencies: [
.package(url: "https://github.com/you/swift-libfoo.git", branch: "main"),
],
targets: [
.target(
name: "MyCLibThatNeedsLibfoo",
dependencies: [
.product(name: "libfoo", package: "swift-libfoo"),
]
)
]
This is the pattern swift-tor uses to depend on this package’s libevent product:
// From swift-tor's Package.swift
.target(
name: "libtor",
dependencies: [
.product(name: "libcrypto", package: "swift-openssl"),
.product(name: "libssl", package: "swift-openssl"),
.product(name: "libevent", package: "swift-event"),
]
)
The downstream target gets the libevent headers on its -I path and links the libevent object files transitively. No duplicate libevent build, no source vendoring on the consumer side.
Step 5 — Test that downstream consumers actually work
The single most useful test you can write for a C-bindings package is “another SwiftPM target can import this and call into it.” This package’s libeventTests target does exactly that — the higher-level Swift API (EventLoop, Socket, ServerSocket, SocketAddress, SocketError) lives in a separate target (Event) that depends on libevent, proving the pattern from the consumer side too:
import XCTest
@testable import libevent
final class libeventTests: XCTestCase {
func testExample() throws {
_ = event() // Constructs a libevent `event` struct — proves the C
// headers are visible and the linker resolved.
}
}
If this test passes, your downstream consumers will be able to import your C product. If it fails (typically with “cannot find ‘X’ in scope” errors), your module map is wrong — usually the shim header is missing an #include.
For higher-confidence coverage — particularly for the C++ interop case Step 3 warns about — also build a separate SPM package that depends on yours via path: "../.." and consumes it from a Swift target with interoperabilityMode(.Cxx) over a C++ shim. This package’s Examples/LinuxConsumerProbe/ is the worked example: it has four targets (pure C++, plain Swift, Swift-with-Cxx-interop over a C++ shim) that together exercise every modulemap regression in one swift build. Wiring the probe into both the Linux Dockerfile and the macOS CI job means a regression fails the build before it can ship to consumers.
Embedded Swift compatibility (forward-looking)
Apple’s Embedded Swift push (WWDC 2024-25) opens a new audience for C-bindings packages: microcontroller and bare-metal Swift code that needs minimal-runtime C interop. Today, the Event Swift API uses Foundation, CheckedContinuation, and other features that aren’t yet available in Embedded Swift mode — so Socket, ServerSocket, and EventLoop won’t compile under Embedded Swift today. But the libevent C product itself has no such dependency and should be consumable from Embedded Swift targets that can build libevent’s own dependency footprint.
This is forward-looking — we have not yet certified swift-event’s libevent product against the Embedded Swift toolchain. If you’re trying this, file an issue with the specific build error and we can iterate.
Common pitfalls
“Header X not found” inside the C library’s own .c files. Your cSettings is missing a header search path. Add .headerSearchPath("include/foo") for any subdirectory the C sources #include "foo/bar.h" from.
Swift consumer can’t see a symbol you expected to be public. The header that declares it isn’t reachable from your shim. Add it to swift-shim.h. Resist the urge to “simplify” by switching to umbrella "." — see Step 3’s warning.
Downstream C++ consumer fails with module '<libfoo>' is incompatible with feature 'cplusplus'. Your modulemap is claiming the C headers (almost always via umbrella "." or umbrella header "foo.h"). Switch to the shim-header form in Step 3 so the <foo/...> headers stay unclaimed and resolve as textual includes via the -I path.
ambiguous use of 'X' errors in Swift consumers. Your library exports a symbol that collides with one from Darwin / Glibc (e.g., libevent’s EV_TIMEOUT collides with kqueue’s EV_TIMEOUT constant on Apple platforms). Disambiguate at the call site with module qualification: libevent.EV_TIMEOUT. The Event package’s own schedule(after:_:) implementation does exactly this.
Warning
Without module qualification, the Swift compiler picks one of the colliding EV_TIMEOUT definitions arbitrarily — usually the wrong one. The compile error is loud (ambiguous use of 'EV_TIMEOUT'), but a successful build with the wrong constant value can produce silent runtime misbehavior (events that never fire, or fire constantly). Always qualify when both modules are imported in the same file.
undefined symbol at link time but compile succeeds. A .c file is excluded from the build (via exclude:) or its source isn’t in Sources/<target>/. Check Package.swift’s exclude: list and your file layout.
Tests pass on macOS but fail on Linux. Almost always a _GNU_SOURCE / __GLIBC__ issue. Add platform-conditional defines in cSettings and check event-config.h-style hand-maintained config files for stale assumptions.
See Also
Related Documentation