FFI with C
How C enters: @cimport
Section titled “How C enters: @cimport”@cimport("header.h") brings C inside. It runs at comptime, invokes a C compiler embedded in the toolchain, translates the whole header (functions, structs, types, macros) and returns a module. You re-declare no prototype: you point to the .h and use it. It is Zig’s approach (@cImport), for the same reason. It opens the whole C ecosystem “for free”: SDL2, Vulkan, SQLite and libcurl just start working, because the compiler reads the official header instead of you transcribing signatures (and getting it wrong, turning it into UB). The cost is a C compiler inside the toolchain, real weight, paid by the ergonomics.
Two caveats. The first: @cimport is not user comptime (section 10), it is a compiler phase, which reads the .h and runs the embedded C. The “pure comptime, no IO” holds for your code, not for this phase. The second: since the C compiler and libc/musl are shipped and located with the toolchain (our compiler is also a C compiler), the build is deterministic given the pinned toolchain, without depending on the C-compiler nor the system headers. A @cimport of a system header, that one does tie the build to the environment, and it is recorded as a dependency (as the mk.sum locks deps, section 15).
C has bitfields (unsigned x : 3;), but the rule “sub-byte width at the C boundary is an error” (section 14) holds here too: a C struct with a bitfield, imported, becomes an opaque type. You hold the pointer and pass it back to C, but you do not read nor write the fields through the language (it is what Zig’s translate-c does, demote-to-opaque). The “for free” promise has that caveat: structs with bitfields come as an opaque handle, not field by field. (bindgen-style generated accessors, with getters and setters that mask the bits, stay as a future extension if there is demand; for now, opaque.)
@cimport has full macro support. Enum constants and object-like #defines with a literal value (EXIT_FAILURE 1, UINT8_MAX (0xff)) are always constants; a #define whose body is an expression ((1 << 20), (BASE + 5), (-1-0x7fffffffffffffff)) is resolved on demand — only the c.<mod>.<NAME> names your program uses, each checked by the embedded C compiler (--cimport-all-macros resolves every one of the header’s) — and that includes a macro defined by a header the imported one merely includes (EINVAL lives in <bits/errno.h>, not <errno.h>): naming it is the demand. An expression that folds to a constant is a constant; a C constant has no fixed width, like a literal, so it takes its context’s (exit(c.stdlib.EXIT_FAILURE) passes a C int, m: u64 = c.stdint.UINT64_MAX keeps all 64 bits). An expression that does not fold is read through a small generated C function each time you read it. This covers errno, which in modern libc is not a plain global but a macro (#define errno (*__errno_location())): c.<mod>.errno is a readable value (each read evaluates *__errno_location(); it is read, never assigned – the same as every member of a @cimport: a constant, a macro, a function), so the ubiquitous C error-checking idiom (if c.errno.errno == c.errno.EINTR { … }) works without you reaching for __errno_location() by hand — read it right after the failing call. A string macro (#define VERSION "1.2") arrives like any C string, raw (Optional[*i8]), and from_cstring turns it into a string. A macro whose value is a C struct (#define ORIGIN ((struct pt){0, 0})) is a value of that struct, returned by value like a C function returning it: bind it, pass it, or read a field straight off it (c.geo.ORIGIN.x). Function-like macros are callable through a generated C shim (c.math.isnan(x)). A macro has no signature of its own — what it accepts and yields depends on its arguments — so each call is instantiated by the C types of its arguments, as C would: c.h.TWICE(n) with an i32 is an int instance returning int, with an f64 a double one, with a u8 it follows C’s promotion to int; an untyped integer constant is a C int (C’s rule for a literal), an untyped float a double; integers of every C width, floats, bool, pointers and @cimport structs cross. A variadic macro (#define LOG(fmt, ...)) takes any number of extra arguments, each call its own instance. A macro whose result is void is a call with no value; one that assigns to its argument (#define INC(x) ((x)++)) is refused, since a Makoto call passes values and the write would land on a copy; a call inside a generic function needs a concrete argument type, since C has no generics. Each distinct combination gets its own shim, and a macro is only callable — it has no type to bind or pass. The one kind with no counterpart is a macro that works on its argument’s tokens rather than its value — stringizing (#x) or pasting (a##b) — since a Makoto call passes values, never source tokens; those are not exposed. Naming a macro Makoto cannot use is a compile error that says why — its body does not compile as a C expression (with the C compiler’s own message), or it works on tokens — rather than claiming the name does not exist.
Each @cimport creates a submodule under the root-namespace c, the root of all FFI, which arises with the first @cimport. The submodule’s name is the header’s basename (no path, no extension), automatic, without you declaring it:
@cimport("stdio.h") // → c.stdio@cimport("SDL2/SDL.h") // → c.SDL@cimport("SDL2/SDL.h") as gfx // → c.gfx (renames the submodule; 'as' for collision or clarity)
unsafe c.stdio.printf(fmt, x) // all qualified: c.stdio.printf, c.SDL.SDL_Init, c.SDL.SDL_INIT_VIDEO...Everything from C stays under c.: functions, types, constants, structs, with no exception. No FFI in your global scope (the pollution of #include). The c. on each use marks “this is C”, the boundary stays visible at every point of contact, and your namespace stays clean (FFI does not pollute whoever does not do FFI, the opt-in test of section 1). The as is the same rule as use ... as (section 14), for a basename collision (two foo.h in different paths) or clarity. And since @cimport is resolved at compile time, it falls into the comptime and modules that already exist (section 15): it is the conditional use, applied to C.
Types at the boundary: all qualified, including the fundamental ones
Section titled “Types at the boundary: all qualified, including the fundamental ones”C’s types are not your language’s types, they are members of the C module, just as c.stdio.printf is a function of it. There is no loose c_int/c_char; everything comes qualified from a @cimport. This holds even for the fundamental types (int, char, size_t), for a reason that is the premise of FFI with C: those types depend on the platform. There is no “C’s int”, there is “the int under a given ABI”. And the axis that actually decides the width is the psABI (platform + architecture), not the compiler: on one ABI gcc and clang agree (int==32, long==64 on System V AMD64), and it is the ABI that differs — long is 64 on System V but 32 on Windows x64 (LLP64), plain char is signed on x86 but unsigned on ARM/RISC-V, and long double is x87 80-bit on System V yet IEEE quad-128 on AArch64. So the canonical spelling names the ABI:
@cimport("systemv") // → c.systemv (also: win64, aarch64_aapcs, riscv64, wasm32)
size: c.systemv.size_t = n // the size_t under the System V AMD64 psABI: correct size/ABIcount: c.systemv.int // 32-bit; c.win64.long would be 32, c.systemv.long is 64The compiler names remain as aliases: c.gcc.int / c.clang.int resolve to the ABI of the target that compiler is configured for, so c.gcc.long is 64 when compiling for Linux and 32 when compiling for Windows x64 — the compiler is the agent that carries the target’s ABI, and naming it is shorthand for “the int as gcc builds it for this target”. (As a side effect, cross-compilation becomes swapping the target, and the sizes follow.)
The everyday spelling is shorter still: the fundamental types are also members of c itself — c.int, c.long, c.char, c.size_t, c.void — and they always follow the compile target’s ABI, like the compiler aliases: c.long and c.size_t are 64 bits on x86-64, AArch64 and RISC-V Linux and 32 on wasm32, and c.char is signed on x86-64 and wasm32 but unsigned on AArch64 and RISC-V. So @callback fn twice(x: c.int) -> c.long is right on every target with no change. You name an ABI (c.systemv.long) only to pin one regardless of the target — reading a Windows struct on Linux, say. It is still the single rule, without exception: every C symbol, including the base types, comes from a @cimport and lives under c. — a fundamental type directly (c.int, the target’s) or under a source (c.systemv.int, an ABI name, target-independent; c.gcc.int / c.stdlib.int, a compiler/runtime/header name, bound to the compile target), and a function, struct or constant under its header (c.stdio.printf).
@cimport works the same under a cross --target (aarch64, riscv64, wasm32): the header is read with that target’s own C library and ABI, so long, size_t and char take its widths and signedness, and a constant takes its value (c.errno.EINVAL is 22 on Linux, 28 on WASI). A narrow integer (char, short, bool) crosses widened the way that ABI says, and a struct crosses by value with that ABI’s own rules (off x86-64, through a shim the target’s C compiler builds). A function the header declares but the target’s C library does not define — WASI has no getpid — is a link error that names it and quotes the header’s own reason. C++ headers import on every target too: each target’s C++ runtime (libc++, libc++abi, libunwind) is vendored beside its C library and linked when the program imports a C++ header, exceptions included — on wasm32 with the standard exception instructions (exnref), so the C++ a wasm32 program links is compiled with -fwasm-exceptions -mllvm -wasm-use-legacy-eh=false.
Pointers and null. C’s T* becomes *c.<...>.T; and since C has NULL and you have Optional (section 6), a C pointer that can be null arrives as Optional[*c.gcc.char]: C’s null is the Optional’s None, matched normally. You do not deref an Optional without handling it, so C’s null possibility becomes explicit handling on your side.
Structs and layout. Your structs are optimized by default: the compiler reorders fields to minimize padding. This is the opposite of the C layout (fields in the order written), so passing a struct to C requires @repr(c), which turns off the reordering and gives a layout faithful to C:
@repr(c)decl Point { x: c.gcc.int; y: c.gcc.int } // faithful order, C padding: safe to cross the boundaryThe symmetry holds on the other side: structs coming from C (via @cimport) already arrive with C layout by origin, they are @repr(c) automatically, you mark nothing. Only your structs, going to C, need the marker. (Since @repr(c) turns off the optimization, the default without a marker remains the fast common case; the marker appears only in the minority that crosses the boundary.)
Enums at the boundary. A C enum is an int, so a C enum imported through @cimport is int-compatible at the boundary: its constants take part in bitwise |/& and pass where a C int is expected, with no extra marker — this is what makes the ubiquitous flag idiom (open(path, c.fcntl.O_RDONLY | c.fcntl.O_WRONLY)) just work. Going the other way, exporting one of your enums to C (@extern) depends on its shape: a payloadless enum (decl Color { Red; Green; Blue }) crosses as its integer discriminant (0/1/2); a payloaded one (decl Shape { Circle(float); … }) has no plain-int form and must be given an explicit @repr(c) tagged layout (a struct of tag + union) or it is rejected. A native Makoto enum stays a strict ADT — it is exactly one variant, and bitmasking would break that invariant — so A | B is not valid on a bare enum. When you genuinely want a native flag set, mark it opt-in with @flags (@flags decl Perms { Read; Write; Exec }): that makes it int-backed, so Read | Write is a valid value of the type (the bitflags/Zig model). Only C enums and @flags enums are bitmaskable; a plain enum never is.
Unions at the boundary. A raw union (section 5) is already a pure-overlap layout identical to C’s, so it crosses freely; the explicit @repr(c) union { … } spelling is accepted as the marker that pins that C-ABI overlap (a no-op today, since native and C union layout coincide, but the honest future-proof marker should the native union ever grow a hidden tag or alignment — exactly parallel to @repr(c) on a struct).
The other marker is @repr(packed), the Zig-style bit-packed layout (section 14): the struct becomes a single host integer and each field takes its exact bit width, so a u3 and a u5 share one byte. It is for bit flags and wire/hardware records, not the C boundary (a bit-packed field has no C ABI, and no *T, since it does not sit on a byte). The layout of any type is observable at compile time through mem.size_of[T](), mem.align_of[T](), and mem.offset_of[T]("field") (the offset follows the reordering). These are ordinary generic functions in the mem package written in Makoto over compiler.reflect (not compiler builtins): size_of/align_of fold to a constant yet stay callable at runtime, while offset_of is a comptime fn (bind its result to a const), and offset_of of a sub-byte or packed field is a compile error, because such a field has no byte offset.
Ownership: @mm(c), @promote and @pin
Section titled “Ownership: @mm(c), @promote and @pin”C does not know your @mm. There are three situations, all reusing section 5.
Memory that C gives you arrives marked @mm(c), a MemoryManager (section 5) whose implementation points to C’s free (the deallocator), not yours. This carries the information that @mm(none) would lose: C memory is not freed with your free, it is freed with C’s, and the @mm(c) knows it. To bring the memory into your world (stop depending on C), use @promote, which already means “copy to another MM”:
data: *Record @mm(c) = c.sqlite3.get_record(...) // C memory, under @mm(c)mine := data @promote(gc) // repatriate: copies to my GC, decouples me from C@promote from @mm(c) to your MM is literally “take it from C, bring it to me”. The @mm(c) is just another MemoryManager, so @transfer and @promote handle it with nothing new.
Memory from C also keeps C’s contents contract. Makoto defines every byte of the memory it owns (section 5: bindings are born with a value, mm.alloc zeroes, a union zeroes what its field does not cover), but a block from C’s malloc or realloc holds whatever C left there, and the language does not change that: libraries depend on C’s behavior, and what a C function wrote into a buffer is beyond what the compiler can see. The memory analyzer only points at the clear case, as a hint (-Whints, an error under --strict-mm): a read through such a pointer that comes, in source order, before anything wrote it, meaning no write through the pointer and no function (C or Makoto) handed it. calloc zeroes, so it is not flagged; passing the pointer anywhere ends the tracking, so the hint never claims a buffer is unwritten when a C call may have filled it.
buf: *u8 @mm(c) = (unsafe c.stdlib.malloc(16) assume "C owns this block").or_panic()first := unsafe *buf assume "read" // hint: nothing has written this C memory yet -- write it first or use callocYour memory that goes to C: if the object is non-movable (@mm(arena)/none/c), it crosses directly (it is already stable). But a @mm(gc) object is the dangerous case, because the collector can move or free the object while C holds the pointer, and C reads garbage. The rule is hard and single: passing a @mm(gc) object to C requires @pin, and without @pin it is a compile error. The @pin fixes the object (the GC does not move nor collect while the pin lives); @unpin releases:
@pin handle // fixes: the GC does not touch it while pinnedunsafe c.SDL.SDL_AddEventWatch(cb, handle) // C can hold the pointer safely// ... later, when C is done:@unpin handle // releases: on YOUR accountReleasing the pin is your responsibility, and forgetting is a debt, like leaking @mm(none) memory. The compiler forces you to put the @pin (otherwise it does not compile), but it does not hold your hand to release it: it is unsafe territory, and you signed up when you called C. A non-movable object does without the @pin.
Safety: calling C is unsafe
Section titled “Safety: calling C is unsafe”Every call to C is a black box that can violate any invariant of yours, so calling C is an unsafe operation (section 5). A C function is an unsafe function of ours: you kill it with assert or assume on the operation, or group several calls in an unsafe block that carries one shared trailer. The block alone discharges nothing (section 5: unsafe { } is not a region of permission), so a bare block of C calls does not compile; the trailer goes after the block’s }:
n := unsafe c.unistd.read(fd, buf, len) assume "fd valid and buf holds len bytes"win := unsafe { c.SDL.SDL_Init(c.SDL.SDL_INIT_VIDEO) return c.SDL.SDL_CreateWindow(...) // the block hands a value out by explicit `return` (section 5)} assume "SDL is used only from the main thread" // one trailer covers every call in the blockAnd the C return is handled at the boundary, but without magic conversions. A pointer that C can return as NULL arrives as an Optional (section 6), matched normally — that mapping is structural (a nullable pointer is an Optional). A const char* (a C string), however, arrives raw, as a *c.<...>.char: it is bytes, with no Display and no string invariant, not a string. Turning it into a string is an explicit call, from_cstring(p), which validates UTF-8 and returns a Result[string, error] (the inverse of .to_cstring(), section 16). The boundary does not silently turn C bytes into a string — you ask for the conversion, and you handle the Result. After that validation, you operate with the normal guarantees.
Variadics (c.stdio.printf(fmt, ...)) are transparent: the @cimport saw the ... in the prototype, so c.stdio.printf accepts N arguments and you just pass them, without annotating variadicity (it came from the header). But C’s variadic does not check types (getting it wrong is UB) and the signature does not say the expected types, so the types of the arguments are your responsibility. It is a classic case-B of unsafe (section 5): the judgment “the arguments match the format string” is human and inexpressible, killed by an assume on the call (or by the shared trailer of the unsafe block that groups it):
_ := unsafe c.stdio.printf("%d items, %s".to_cstring(), count, name.to_cstring()) assume "count is an int and name a C string" // YOU guarantee that the types match the formatCallbacks: @callback, @extern and spawn over the bridge
Section titled “Callbacks: @callback, @extern and spawn over the bridge”There are two senses of “C calls back”, and two markers:
@callbackmarks a function of yours to be passed as a callback to a C function (c.stdlib.qsort(...)). It gains C ABI so C can call it.@externexposes a function of yours with C ABI to be a lib that C consumes (the sense “being called by C”).
@callback fn compare(a: *c.gcc.void, b: *c.gcc.void) -> c.gcc.int { // void* → typed pointer: the dedicated unsafe primitive (both type-args explicit) ia := unsafe mem.reinterpret[c.gcc.void, i32](a) // *void → *i32 ib := unsafe mem.reinterpret[c.gcc.void, i32](b) return (*ia - *ib) as c.gcc.int}unsafe c.stdlib.qsort(arr, n, sz, compare)
@extern fn my_library_init() -> c.gcc.int { ... } // external C can link and call thisReinterpreting a pointer at the boundary: mem.reinterpret. A C comparator receives void* and must read the real type behind it — the ubiquitous FFI move (qsort, bsearch, pthread userdata, generic containers). This is a pointer reinterpretation, and it is neither as (which is value conversion only — width/repr, never pointers) nor a union (which is byte type-punning of values, section 5). It has its own dedicated, unsafe-gated primitive: unsafe fn mem.reinterpret[T, V](p: *T) -> *V, taking a *T and returning a *V. It is declared unsafe, so every call sits in an unsafe context, exactly like a C call; and it is named (greppable — every pointer reinterpretation in the program is a reinterpret, never a silent cast). Both type-args are explicit at the call site ([c.gcc.void, i32], source-first: “from void to i32”): the source T could be inferred from the argument, but the language does not allow partial type-argument application (all inferred, or all explicit; section 14) — Makoto always answers “where did this type come from”.
A @callback/@extern function may return an aggregate by value (a @repr(c) struct returned directly, not only through an out-pointer): it rides the same C-ABI return machinery the inbound direction already uses — small aggregates in registers, larger ones via the ABI’s sret hidden pointer — so the two directions are symmetric (a C function imported through @cimport – or declared by a bodyless @extern – can return a struct by value, and yours exported to C can too). The struct must be @repr(c): without it Makoto may reorder the fields, and C would read the wrong layout; the compiler rejects it with that reason. Each target’s C ABI returns the struct its own way (in registers, as an HFA in floating-point registers, or through the hidden result pointer), and the compiler leaves that to the target’s own C compiler, so it is the same on every target.
The thorny case is inside a callback. C calls your callback from a raw C thread, without your scheduler, your isolation nor your process runtime (section 8). If the callback does spawn, it cannot create a process there. The solution is the runtime as the middle-man: the spawn in the callback does not create the thread locally, it becomes a request to your runtime (“create the process worker(x) in the right world and give me back the channel”), and the runtime, on its side, creates the process in the right place (your scheduler, your isolation) and returns the channel.
The same principle closes the @callback contract: the body does not suspend and does not allocate. Local heap allocation and io.* in there are compile errors, because there is no @mm nor process on the raw C thread to sustain them (the @mm comes from the process, section 5; the io.* suspension needs a process to suspend, section 12). What is allowed is talking to the runtime and the processes: sending on a channel, and the spawn-over-the-bridge above. Even if the message allocates, whoever allocates is the runtime on the other side, not the callback. The line is this: communication with the runtime and processes is an allowed bridge; local heap allocation or IO is forbidden. A @callback is a thin bridge; anything “real” (allocate, do IO, create a process) crosses through the middle-man.
The syntax is identical inside and outside: you write spawn worker(x) the same, and at the boundary the spawn is just routed by the runtime. And since that route can fail (runtime unavailable, resource exhausted), it uses the form the language already has, the catch of the spawn (section 3):
@callback fn on_event(ev: *c.SDL.SDL_Event) -> c.gcc.int { spawn handle_event(ev) catch |e| { return c.SDL.SDL_FALSE } // spawn over the bridge; can fail return c.SDL.SDL_TRUE}A spawn that crosses the boundary is just a spawn with one more way to fail, and the catch covers it, wherever the failure comes from. The raw C thread never operates your scheduler; it requests, and the runtime executes. (It is BEAM’s model for NIFs and Go’s for cgo: foreign code does not become a process nor a goroutine, it communicates with one, and that is already your model of channels between isolated worlds, section 4.)
Build and linking: cdeps
Section titled “Build and linking: cdeps”The compiler compiles the .c together (Zig style, and it is what makes sqlite.c “just work”: the .c enters the build, it is not a pre-compiled binary that you pray exists). How it discovers what to link follows the reproducibility that governed strings and modules: explicit declaration, not auto-discover.
Auto-discover (the compiler scans the @cimport and guesses the -lSDL2 of the environment) is magic when it works and breaks reproducibility when it does not. It runs on your machine, fails on CI with another version of the lib, and there is nowhere to record which version. It is the problem that mk.sum solved. So C deps are declared in cdeps in the mk.project (section 15), like the Git deps: name, version, checksum:
cdeps { SDL2 2.30.1 sha256:... sqlite3 3.45.0 sha256:...}The @cimport("SDL2/SDL.h") says which header; the cdeps says which SDL2, from where, with what hash, the same separation “code points, manifest governs version and proof” as the rest. The build becomes reproducible. The exception is the omnipresent system libs (libc and company): always present, not versioned by your project, do without declaration. The rest (SDL2, SQLite, Vulkan) is declared.
Which C library: musl, glibc, the system’s
Section titled “Which C library: musl, glibc, the system’s”The C library a program links is a build flag, --libc, the same for mk build, mk run and the drop-in mk cc/mk c++, and every choice but the last comes from the toolchain, not the machine:
musl(the default): linked statically, so the binary depends on no C library at all and runs on any Linux of its architecture.glibc, orglibc.2.<minor>for a given release: linked dynamically against glibc, as Zig does it. The toolchain carries glibc’s headers, start files and the list of every symbol each release exports with its versions, never glibc itself: the program is linked against stand-ins written for the targeted release, records the symbol versions that release has, and runs on any glibc at least that new — the newest release by default, any from 2.17 (2.27 on riscv64) on request, so a binary for older systems is one flag away. It is how a program gets what musl does not give — the GNU extensions, NSS,dlopen, iconv, locales — and the sanitizers (ThreadSanitizer and the others exist only on glibc). There is no static glibc: NSS, iconv anddlopencannot work in one, and a self-contained binary is what musl is for.system: the host’s own headers and libraries, for a build that must use the machine’s C environment as installed.
Sanitizers: --sanitize
Section titled “Sanitizers: --sanitize”mk build and mk run take --sanitize=address, thread or undefined (a comma-separated list; address and thread exclude each other), and the drop-in mk cc/mk c++ take clang’s own -fsanitize=. The sanitizer runtimes exist only for glibc, so a sanitized build links the vendored glibc: leaving --libc out selects it, and asking for another C library alongside --sanitize is an error rather than a silent switch. They are native only — not for WebAssembly or JS.
What gets checked is everything the program carries. The program’s own functions are instrumented (codegen marks each one for the sanitizer), so a read past a C buffer through an unchecked [*]T index is reported in the Makoto function that did it. The runtime is compiled with the sanitizer too, and it announces each switch between process stacks — every process is a fiber to ThreadSanitizer and a separate stack to AddressSanitizer — so the scheduler, the channels and the collector are checked as they really run, without the switches reading as bugs. The C the program links (its @cimport shims, the C sources and objects given to mk build) is compiled with the same flags.
addressreports out-of-bounds and use-after-free on C and runtime memory, and leaks at exit. It keeps every local on its process’s own stack (no use-after-return frames on the side): the runtime tells a stack slot from heap memory by its address — the owner of a value is the place holding it, section 5 — and Makoto never lets a local’s address outlive its frame anyway.threadreports data races. Makoto code shares no mutable memory between processes, so what it finds is in the C a program calls and in the runtime itself. ThreadSanitizer follows at most 65,536 nested calls per process (its shadow call stack has a fixed size), so a program that recurses deeper — every stack-overflow test does — is not one to run under it.undefinedreports undefined behaviour in that C (signed overflow, a null passed where it may not be, a bad shift, …) and is fatal: the first report ends the run with exit status 1. Makoto’s own arithmetic, bounds and casts already trap on their own.
A --emit=lib library built with --sanitize is instrumented the same way; the program that links it must be linked with the same sanitizers.
Build-system interop: CMake and Ninja
Section titled “Build-system interop: CMake and Ninja”The FFI lets a Makoto program call C; the build interop lets a Makoto module drop into a C/C++ codebase without that codebase rewriting its build. This is the adoption path: a team with an existing CMake or Ninja tree wants one module in Makoto, incrementally, not a rewrite. It goes both ways, and neither way invents a second build system — each side keeps its own and calls the other at a thin boundary.
A Makoto build calling cmake/ninja is just the task runner (section 22). A task declares the subprocess it runs, so building a C dependency that ships its own CMake, or invoking a project’s ninja, is a [tasks.<name>] with run = cmake --build build and cmake (or ninja) in its subprocess list. The declared-subprocess gate already governs it; there is nothing new to learn, and the hermetic-by-convention rule (section 22) still holds — an undeclared tool is refused.
A cmake/ninja build calling Makoto is mk export. Since mk build --emit=lib already produces a C-callable static library plus a generated header (section 17, the two-way boundary), the export is thin glue that shells out to it at build time:
mk export cmakewrites aMakotoConfig.cmake— a reusable module a C/C++ projectinclude()s (orfind_package(Makoto)s), giving itmakoto_add_library(<target> <entry.mko>)andmakoto_add_executable(...). The library builds throughmkduring the cmake build and links like any otherIMPORTEDtarget:target_link_libraries(theirapp PRIVATE mymod). It accepts the build’s own knobs (RELEASE,TARGET <arch>,LIBC <name>).mk export ninjawrites a ninja file with amakoto_librule and abuildedge for the project’s entrylib<name>.a+ header, run standalone (ninja -f makoto.ninja) orsubninja’d into a larger graph.
The C side compiles with mk cc (the drop-in of section 17, so it links against the same hermetic toolchain the library was built with — set CMAKE_C_COMPILER to it), or, for a project that links with the host toolchain, the library is built LIBC system to match. Either way the discipline is the design’s throughout: Makoto owns the Makoto build (one compiler, reproducible), the foreign build owns its own, and the two meet at a declared, versionable boundary — the same mk.project/mk.mod that governs a pure-Makoto build, now reachable from the outside.