Edit the TOML
replibuild.toml is the interface. Every piece of library-specific knowledge lives in this file — not in a generator fork, not in a patch, not in a hand-written shim. discover() writes what it can see by scanning. The rest is what you tell it, and §2 is the list of exactly that.
How to get to a file you can edit: Wrap a library. Symptom → key: Troubleshooting. The tables below are what the parser actually does — including keys it validates loudly, keys it warns about, and the handful it currently accepts and ignores.
1. How the file is created and maintained
toml = RepliBuild.discover("path/to/project") # writes replibuild.toml
RepliBuild.discover("path/to/project", force=true) # rescan, preserving intent keysDiscovery scans sources, walks the #include graph, picks wrap.language from the file extensions it found, and writes a config that compiles. It cannot infer anything that is not visible in the source tree's shape — see §2.
Forced re-discovery preserves hand-curated keys. discover(force=true) regenerates the file, and without carry-over it would emit these empty, silently destroying your intent. The preserved set is:
| Section | Key |
|---|---|
[types] | templates |
[types] | template_headers |
[wrap] | varargs |
[wrap] | macros |
[wrap] | shim_headers |
[wrap] | cstring_owned |
[wrap] | exclude_symbols |
[wrap] | surface_types_only |
[wrap] | surface_types_extra |
[wrap] | tier1 |
[link] | promote_statics |
A regenerated non-empty value wins; an empty or absent one gets the preserved value. A hand-edited key not in that table is wiped by force=true. [compile] flags, include_dirs, and [dependencies] are not preserved — keep a hand-written config in version control rather than relying on force=true to round-trip it.
save_config only writes keys with non-default or non-empty values, so a round-tripped file is smaller than the full schema. Absent keys take the parse-time defaults in §3, which are not always the same as what discover() writes — the divergences are flagged in the tables.
2. What discovery cannot know
This is the important section. Everything here is information that exists only in a human's head or in the upstream build system, and that RepliBuild will not guess. Omit it and you get a build that succeeds and a wrapper that is missing things or is wrong.
| If your library… | You must declare | Symptom if you don't |
|---|---|---|
| is C++ and you want STL/template types | [types] templates + template_headers | The type never appears in DWARF; members typed Any, methods missing |
| exposes API through preprocessor macros | [wrap.macros.*] + [wrap] shim_headers | The macro simply does not exist in the wrapper — no error |
| has variadic functions worth calling with arguments | [wrap.varargs] | Only the zero-variadic base wrapper is generated |
returns malloc'd char* | [wrap.cstring_owned] | Every call leaks the C buffer |
| links generated or vendored C++ you don't want wrapped | [wrap] exclude_symbols | Thousands of non-API symbols wrap; reaching for -fvisibility=hidden instead can erase the whole API |
| compiles vendored internals whose TYPES land in DWARF | [wrap] surface_types_only | Every internal type is emitted; onednn's 2900 unreachable ones shadow Base.Integer and emit duplicate fields, so the module fails at include |
| defines its public "def" structs header-only | -fstandalone-debug in [compile] flags | Structs wrap to empty Julia structs |
| ships tests/examples/backends in the same repo | exclude on the dependency | Unrelated sources compile in and pollute the DWARF |
keeps headers somewhere other than <root>, <root>/include, <root>/src | [compile] include_dirs | Compile failure, or a TU silently missing declarations |
needs -lm, -lpthread, -ldl | [link] link_libraries | Loads fine; Tier-1 slices demote, or a symbol is missing at first use |
is configured by its build system through -D | [compile] flags / [compile] defines | One TU fails to compile, or a feature is silently off |
| annotates its exports and you want internals out of the wrapper | [compile] visibility = "hidden" — after running the probe (§2.12) | Without it the wrapper carries every internal symbol as public API |
| is C and you want cross-language inlining | [wrap.tier1] enable = true | Everything stays Tier 3 ccall (correct, just not inlined) |
| must be reproducible | commit on the dependency | A moved tag silently changes what you compiled |
2.1 C++ templates and the STL — "STL needs STL in the TOML"
A template that is never instantiated does not exist in the binary. The compiler emits DWARF for code it generated; an uninstantiated std::vector<int> generated nothing, so there is nothing to read. No amount of header scanning changes this — the information is not missing, it was never created.
So you name the instantiations you want, and RepliBuild writes a stub .cpp that forces them:
[types]
templates = ["std::vector<int>", "std::string", "std::map<int, int>"]
template_headers = ["<vector>", "<string>", "<map>"]templatesentries are C++ type spellings, exactly as you would write them intemplate class X;.- Common STL headers are auto-detected from the type name (
std::vector→<vector>,std::map→<map>, and the same forunordered_map,set,unordered_set,deque,list,basic_string/string).template_headersis for everything else — your own headers, or a library's:templates = ["fmt::basic_memory_buffer<char>"] template_headers = ["<fmt/format.h>"] - Typedef spellings are expanded for you where an explicit instantiation would otherwise be invalid C++:
std::string→std::basic_string<char>, and the same forwstring/u16string/u32string. - Entries starting with
<are emitted as#include <…>; anything else becomes#include "…".
Once instantiated, the STL method symbols are extracted (extract_stl_method_symbols) and become container accessor thunks. A C++ library whose public API is STL-free needs none of this — box2d 2.4 reports stl_methods = 0 and declares no templates.
[types].templates is one of the preserved keys precisely because a rescan would otherwise wipe it. If your C++ types come back as Any or your struct collapses to an opaque blob, this is the first key to check.
2.2 Preprocessor macros
Macros do not survive compilation, so they are absent from both the symbol table and DWARF. RepliBuild generates a typed C/C++ shim function per declared macro, compiles it into the library, and wraps the resulting real symbol.
[wrap]
shim_headers = ["lua.h", "lauxlib.h"] # what the shim TU #includes
# Function-like macro: `args` present → emits MACRO(arg0, arg1)
[wrap.macros.lua_pop]
ret = "void"
args = ["lua_State*", "int"]
# Value macro: `args` ABSENT → emits the bare MACRO expression
[wrap.macros.LUA_REGISTRYINDEX]
ret = "int"retandargsare C type strings, pasted verbatim into generated C. They are not Julia types. (Contrast §2.3, which is Julia types — this asymmetry is the single easiest mistake to make in this file.)- The presence of the
argskey is the switch, not its contents:args = []means a zero-argument function-like macro (MACRO()), while omittingargsentirely means a value macro (MACRO). - Shims are emitted with
__attribute__((used, visibility("default")))so they survive-fvisibility=hiddenbuilds and LTO internalization — without that, projects like box2d3 would silently lose every macro. - In Julia the
replibuild_shim_prefix is stripped: you calllua_pop(L, 1).
The shim TU is generated under the build cache, not inside the source tree, so a bare #include "blake3.h" can fall through the -I path to a system-installed copy of the header at a different version — and bake wrong values into every macro, with no other symptom. A guard checks each direct shim include and hard-errors if it resolved outside the project/dependency tree. The fix is either a more specific subpath (shim_headers = ["c/blake3.h"]) or an explicit -I<dir> in [compile] flags.
2.3 Variadic functions
DWARF records that a function is variadic. It cannot record what you intend to pass. Declare one entry per concrete overload:
[wrap.varargs]
lua_pushfstring = [
["Cstring"],
["Cint"],
["Cstring", "Cint"],
]Three rules, each of which has bitten someone:
- List only the variadic arguments. The fixed parameters (
lua_State*, theconst char* fmt) come from DWARF and are prepended automatically. - The names are Julia types, not C types. Allowed:
Any,Bool,Cstring,Cwstring,Cchar,Cuchar,Cshort,Cushort,Cint,Cuint,Clong,Culong,Clonglong,Culonglong,Cintmax_t,Cuintmax_t,Csize_t,Cssize_t,Cptrdiff_t,Cwchar_t,Cfloat,Cdouble,Cintptr_t,Cuintptr_t, the sizedInt*/UInt*/Float*types, andPtr{Name}. Anything else is a hard error naming the entry. - It is a list of lists.
f = ["Cint"]is one overload taking… nothing coherent; the parser rejects it and tells you to writef = [["Cint"]].
Vararg wrappers lower as true variadic calls (the @ccall semicolon form), so the x86-64 SysV variadic protocol — including the AL register-count setup that gates the callee's va_start — is formally correct, float varargs included. A Ptr{X} overload sanitizes to _PtrX in the generated function name.
2.4 char* return ownership
Whether a returned char* must be freed, and by what, is not in DWARF and not reliably in the name. Declare it:
[wrap.cstring_owned]
cJSON_Print = "cJSON_free"
sqlite3_mprintf = "sqlite3_free"The wrapper copies the string into Julia and then frees the C buffer through the named symbol. Undeclared functions get the default policy — Union{String,Nothing}, where NULL is a value rather than an exception, and the buffer is left alone. Every Cstring-returning function additionally gets a raw <name>_ptr variant that returns the pointer unchanged for lifetime-sensitive callers. The value must be a non-empty string; anything else is warned about and ignored.
"free" names the C runtime's free, and is routed through the wrapper's libc_free rather than looked up in the library: a Windows DLL does not export what it imports. Every wrapper defines libc_free(ptr) for the same reason a caller needs it by hand — to release any buffer the library malloc'd (mpack's growable writer hands one over). Use it, not Libc.free: on Windows Julia links msvcrt.dll, a RepliBuild-built DLL allocates from the UCRT, and freeing into the wrong heap kills the process with STATUS_HEAP_CORRUPTION. On Linux the two are the same.
A char* return is presented the same way whether the function is called through ccall, a Tier-1 slice, or a Tier-2 MLIR thunk — the tier decides how the call is made, never how the result is presented. That was not always true: until 2026-08-12 the C++ generator's Tier-2 path skipped the policy entirely, so 75 functions across five Hub packages returned a bare Cstring and any cstring_owned declaration on one of them was silently discarded. Both halves now come from one derivation, and a guard on the write path refuses any wrapper in which a char* return escapes as a bare Cstring without a _ptr sibling.
2.5 Header-only aggregates need -fstandalone-debug
Clang's default "limited" debug info emits a header-only aggregate that no TU instantiates as a DW_AT_declaration-only stub. Those are exactly the "def" structs a caller is supposed to fill in — b2BodyDef, b2FixtureDef, llama_model_params, llama_context_params — so they wrap to empty Julia structs and the API becomes uncallable.
[compile]
flags = ["-O2", "-fPIC", "-std=c++17", "-fstandalone-debug"]DWARF is RepliBuild's input format, so this flag is load-bearing, not bloat. Reach for it whenever a struct you can see in the header comes out with no fields.
2.6 Excluding what is not the library
A repository is usually more than the library: tests, examples, GUI testbeds, vendored third-party trees, alternative backends, other CPU architectures. The dependency resolver walks the whole tree, so anything compilable gets compiled and lands in the DWARF the wrapper is generated from.
[dependencies.box2d]
type = "git"
url = "https://github.com/erincatto/box2d.git"
tag = "v2.4.1"
exclude = ["testbed", "unit-test", "extern", "docs"]exclude entries are matched against the path relative to the dependency root, and the match is deliberately generous — an entry hits if it equals the filename, or is a prefix, suffix, or any substring of the relative path. That is why "ggml-cuda/" and "tools/" work as directory filters and "xmltest.cpp" works as a file filter, and also why a very short entry can over-match: prefer "tests" over "t", and keep a trailing / on directory entries. Excluding aggressively is normal — llama.cpp's config excludes roughly thirty paths, each with a comment explaining why.
The walker additionally drops directories named test, tests, testes, example, examples, fuzzing, build, .git, doc, and docs on its own, so those need no entry (several Hub configs list them anyway, harmlessly).
2.7 Include roots
The resolver adds <clone>, <clone>/include and <clone>/src automatically. Anything at a different depth must be named:
[compile]
include_dirs = [
".replibuild_cache/deps/llamacpp/ggml/include",
".replibuild_cache/deps/llamacpp/ggml/src",
]Entries are passed through verbatim as -I<dir>. Relative paths resolve against the directory containing replibuild.toml, because build() runs there.
2.8 Link libraries
[link]
link_libraries = ["m", "pthread", "dl"] # → -lm -lpthread -ldl
link_dirs = ["/opt/thing/lib"] # → -L/opt/thing/libA shared library links happily with sin left undefined, so omitting -lm is not a build error — it is a .so whose DT_NEEDED chain is short. That matters beyond the obvious runtime failure: the Tier-1 slice pre-flight scopes symbol lookup to the library plus its DT_NEEDED chain, so on miniaudio a missing -lm demoted all 51 slices that declare sin/pow/log back to ccall with no other sign.
dl and rt exist only on Linux, and pthread / m mean something different under mingw. A list that is right for one host goes under that host's overlay (§2.15), e.g. [platform.windows.link] link_libraries = [].
[link] link_libraries is the source-build key. [ingest] extra_link_libs is the ingest-mode key. Putting extra_link_libs in a source-build config is silently ignored — it is not part of [link] and nothing reads it there.
2.9 Defines the build system would have supplied
Upstream's CMake or Makefile often injects defines that no header declares. Without them a TU fails to compile, or a subsystem is silently absent:
[compile]
flags = [
"-O2", "-fPIC",
"-DGGML_USE_CPU", # else the backend registry is empty
'-DGGML_VERSION="0.18.1"', # else ggml.c is the one TU that won't build
]
[compile.defines] # equivalent, emitted as -Dkey=value
SQLITE_THREADSAFE = "1"[compile.defines] always emits -Dkey=value; for a bare -DFOO with no value, use [compile] flags. Both feed the compile fingerprint, so changing either correctly invalidates the per-file IR cache.
2.10 Tier 1 (C only) — leave off
[wrap.tier1]
enable = falseenable defaults to false. Leaving it off costs you nothing but inlining: every function stays on ccall, which is correct. The mechanism lives under Developer: Tier 1.
2.11 Pinning content, not just a name
A git tag is a mutable ref. commit is the only check that survives a cache wipe:
[dependencies.lua]
type = "git"
url = "https://github.com/lua/lua.git"
tag = "v5.4.7"
commit = "1ab3208a1fceb12fca8f24ba57d6e13c5bff15e3"It must be a full 40-hex object name — abbreviated SHAs are rejected at config load, because they are ambiguous against future history. A mismatch after clone/checkout is a hard error, not a warning.
2.12 Export visibility — what the wrapper's API is
By default RepliBuild wraps every function that reaches the library's dynamic symbol table. For most C libraries that is far more than the API: internal helpers linked across translation units are externally visible too, and the generated module exports them beside the functions you meant to call. pcre2 ships 27 _pcre2_* internals that way; zstd, 201.
A library can already tell you which is which. Most annotate their public functions — __attribute__((visibility("default"))), usually behind an export macro like PCRE2_EXPORT or B2_API — precisely so a -fvisibility=hidden build exports the API and hides the rest. Turning that on gives you the author's own answer, computed by the compiler:
[compile]
visibility = "hidden" # default: "default"-fvisibility=hidden hides every definition the library did not annotate — including public API whose export macro is a bare extern, which is what lua's LUA_API is. [link] promote_statics then reads hidden as "internal" and renames the whole surface to __rb_*, and the wrapper generates nothing.
Measured on cjson: adding the flag takes it from 92 wrapped functions to dwarf: 0 functions, with all 118 dynamic symbols renamed.
RepliBuild refuses that build rather than shipping it (§5), but a refusal is a late and expensive way to learn a fact about a library. Ask first:
julia> RepliBuild.VisibilityProbe.visibility_probe("packages/pcre2/replibuild.toml")
VisibilityReport: pcre2
sources compiled : 29/29
external defs : 105
library annotated : 78 (74.3%)
macro shims : 14 (always annotated — excluded from the verdict)
verdict : annotated
wrapped today : 119
retained : 92
DROPPED : 27The probe compiles your sources under your flags with the flag added and reads visibility off the IR — no header parsing, no guessing. Read the dropped list before setting the key: those are functions the wrapper carries today, and the probe cannot tell an internal you are glad to lose from a public function upstream forgot to annotate.
The verdict belongs to the configuration, not to the library. Export macros are routinely gated behind a define — cJSON's CJSON_PUBLIC expands to the visibility attribute only when CJSON_API_VISIBILITY is defined, and to a bare type otherwise. cjson measures 0 of 92 annotated purely because its flags omit that -D. Adding it flips the answer. Re-run the probe on a version bump and after any change to flags or defines.
Two mechanical notes:
- The key is honoured through
get_compile_flags, which is what the compile fingerprint hashes, so flipping it correctly invalidates the per-file IR cache. - A raw
-fvisibility=hiddenin[compile] flagsis the older spelling and still works — it is just undocumented and unvalidated. Setting both is fine when they agree (you get one flag); setting the key to"hidden"whileflagsasks for a different visibility is a hard error, because which one decides the library's API is not something RepliBuild should guess.
2.13 exclude_symbols — trimming a surface without touching visibility
§2.12 narrows the API with the compiler. That works when upstream annotates its exports, and it is all-or-nothing when it doesn't. [wrap] exclude_symbols is the other lever: name the symbols that should not reach the wrapper, and leave visibility alone.
[wrap]
exclude_symbols = ["matmul_*", "vk::*"]Reach for it when the library links code that is compiled in but is not API — generated translation units, vendored C++, an embedded resource blob. The case this was built for is Hub llamacpp with ggml's Vulkan backend enabled: 136 generated TUs whose entire contents are SPIR-V byte arrays (1,958 matmul_*_data/_len globals in the largest one alone), plus the vk::*Error class hierarchy out of vulkan.hpp — vtables and thunks included. None of it is callable API, and the generator has no opinion about that: it wraps what nm reports.
Entries are anchored globs, not regexes. * and ? are the wildcards; every other metacharacter is a literal. That is deliberate — you write these against nm output, where (, ), +, ., < and [ are ordinary symbol text (operator+, f(int const&), std::vector<int>) and a literal * never appears. Under regex rules operator+ would mean "one or more operator" and match nothing, silently.
Anchoring means a pattern matches a whole symbol name, so use a trailing * for a prefix. vk will not take out vkontakte_init, and on llamacpp ggml_backend_vk_* drops seven functions while correctly leaving ggml_backend_is_vk alone.
A function matches on any of its name, demangled or mangled spelling — which one you can actually see depends on the symbol. Globals match on name.
A pattern that matches nothing is a hard error. It means the symbol was renamed or dropped upstream, or the glob is mistyped; either way the filter is not doing what its author thinks, and a wrapper that quietly regrows a surface someone deliberately cut is the failure this is meant to prevent. Same reasoning as proven_at in [wrap.varargs]. Filtering away every function is refused too.
Applied at wrap time: changing the filter and re-running wrap() costs no recompile, and compilation_metadata.json keeps the full unfiltered picture that RepliBuildTooling's api_surface reads.
Prefer this to
-fvisibility=hiddenwhen the goal is "hide these symbols" rather than "adopt upstream's export policy". The flag is conditional on an export macro being active, not merely present — see §2.12 and the llamacpp note:GGML_APIexpands to a visibility attribute only under-DGGML_SHARED, and without it the flag hidllama_decodealong with the shader blobs.
2.14 surface_types_only — the same question, asked about types
§2.13 bounds which functions the wrapper binds. This bounds which types it defines, and the two diverge badly for any C++ library that compiles vendored internals into the same .so.
[wrap]
surface_types_only = true
surface_types_extra = ["MD_*_DETAIL", "ErrCodes"] # see the hatch belowOn, the generator emits only the types reachable from the wrap surface — every exported function's return type, parameter types and receiver class, the type of every bindable global, then their members transitively. Off (the default), it emits every type DWARF carries.
Hub onednn measures the gap: 2974 types recorded, 79 reachable (2.7%). The other ~2900 are dnnl::impl and nGEN GPU-JIT internals the C API never names.
This is not a size optimisation. Those internals are what break the module: an nGEN enumerator spelled Integer emits const Integer = Pipe(3), which shadows Base.Integer and invalidates 522 generated v::Integer signatures, and nGEN instruction structs emit duplicate _ fields. The wrapper parses and then dies at include. A 13 MB module became 596 KB — and went from not loading to loading.
Globals are gated on the dynamic symbol table, not on metadata. A global is a legitimate seed — Hub box2d binds all 16 of its b2_defaultFilter-style globals and would lose b2Filter without it — but only when the loader can actually resolve the name. onednn records 332 globals and zero are in its .so's dynsym; they are internals hidden by -fvisibility=hidden. Seeding from those pulled in 58 extra types, Pipe among them, which is to say it preserved the very bug this is meant to fix. When the symbol table cannot be read at all (no library on disk, PE without an export directory), the walk fails safe and seeds from every global: over-inclusion costs unused types, while guessing "unbindable" would drop types the wrapper still emits.
surface_types_extra is the hatch for what reachability structurally cannot see, and it is why this key is opt-in rather than the default. Two blind spots:
- Callback-only structs. A callback's parameter types are reached through its signature, but a struct it receives only as
void *is named nowhere, so a struct a user needs in order to write the callback can be invisible. md4c is entirely this shape: every SAX callback gets itsMD_*_DETAILstruct asvoid *. Measured before callback signatures had parameters, its reachable set was 2 of 36 types, and it ships 231/231 tests against the other 34. - Constant-only enums. A function returning plain
intnever names its error enum, soargon2'sArgon2_ErrorCodesis unreachable by construction.
Patterns are the same anchored globs as exclude_symbols, matched against both the raw key and the bare name, so ErrCodes catches a record keyed __enum__ErrCodes. A keep-pattern that matches nothing is a hard error — sharper than the exclude_symbols rule, because this list exists to retain types, so a silent miss is dropping API right now.
Turn this on when the module fails to
include, not because the wrapper feels large. The default is off deliberately: measured across every Hub package, the filter keeps 95% of box2d and 2.7% of onednn, and would gut md4c.
Related, and not configurable: globals absent from the dynamic symbol table never get an accessor. That one is unconditional, because an accessor for a symbol which is not in the library is a guaranteed failure at the call site rather than a question of how much surface to expose. It is why onednn's wrapper went from 1006 functions to 342 while keeping all 217 of its C API: 656 of those functions were accessors for internals hidden by -fvisibility=hidden, failing as could not load symbol or — where the type had also been filtered out — UndefVarError. llamacpp is the same shape at larger scale (0 of 4099 globals resolvable), while box2d, curl and pcre2 lose nothing. When the symbol table cannot be read the drop is skipped entirely, same fail-safe as above.
2.15 One config, two hosts — [platform.<host>]
Linux and Windows disagree about link libraries, platform defines and which platform sources a dependency must exclude. A [platform.linux] or [platform.windows] table holds the keys that differ, and is merged over the rest of the file on that host only:
[compile]
flags = ["-O1", "-fPIC", "-DLUA_USE_LINUX"]
[link]
link_libraries = ["m", "dl"]
[platform.windows.compile]
flags = ["-O1"] # replaces the base list outright
[platform.windows.link]
link_libraries = [] # empty really means empty
[platform.windows.dependencies.glfw]
exclude = ["x11_", "glx_", "posix_", "linux_"] # merges into [dependencies.glfw]Tables merge key by key at any depth; every other value (string, number, array) replaces the base. Keep the base correct for one host and override only on the other: a RepliBuild older than this table ignores it and sees the base. Platform names are linux, windows and macos; any other name is a hard error, since a misspelled overlay would otherwise vanish on the one host it was written for. discover(force=true) preserves the tables, and register reads the project's root through the overlay of the host it runs on.
2.16 Paths are relative to the project
Relative source_files and include_dirs are resolved against [project] root at load, and a relative root against the TOML's own directory, so a config builds from any working directory and in any checkout. discover() writes paths under the project that way, with / separators; paths outside the project stay absolute. A config with absolute paths into one machine's tree (/home/…) builds nowhere else.
3. Section reference
Types are TOML types. "Default" is what the parser uses when the key is absent; where discover() writes something different, that is called out.
[project]
| Key | Type | Default | Notes |
|---|---|---|---|
name | String | directory basename | Drives lib<name>.so and the module name |
root | String | directory containing the TOML | Absolute or relative |
uuid | String | freshly generated | An unparseable UUID warns and is regenerated. Keep the one discover() wrote — an absent uuid means a new one on every load |
version | String | — | Not part of the schema. Every Hub config carries one and it is useful documentation (it should match the pinned tag), but RepliBuild parses and drops it; the Hub's own index.toml is what publishes a version |
[paths]
| Key | Type | Default | Notes |
|---|---|---|---|
output | String | "julia" | Wrapper + .so + compilation_metadata.json land here |
build | String | "build" | Intermediate IR and objects |
source | String | "src" | Parsed, not consulted — sources come from discovery or [compile] source_files |
include | String | "include" | Parsed, not consulted — include roots come from discovery or [compile] include_dirs |
cache | String | ".replibuild_cache" | Parsed, not consulted — the cache directory is [cache] directory |
[discovery]
| Key | Type | Default |
|---|---|---|
enabled | Bool | true |
walk_dependencies | Bool | true |
max_depth | Int | 10 |
ignore_patterns | Vector{String} | ["build", ".git", ".cache"] |
parse_ast | Bool | true |
This whole section is currently parsed and stored but not consulted by the scanner. It round-trips faithfully; it does not change behaviour. Do not reach for ignore_patterns to trim a dependency — use exclude on the dependency (§2.6).
[compile]
| Key | Type | Default | Notes |
|---|---|---|---|
flags | Vector{String} | ["-std=c++17", "-fPIC"] | discover() writes ["-fPIC"] for a C-only scan |
defines | Table{String→String} | {} | Emitted as -Dkey=value |
parallel | Bool | true | Multi-threaded per-file compilation |
source_files | Vector{String} | [] (auto-discovered) | Explicit list; relative entries resolve against root (§2.16); validated to exist at load |
include_dirs | Vector{String} | [] (auto-discovered) | Passed as -I; relative entries resolve against root (§2.16) |
aot_thunks | Bool | false | Pre-compile Tier-2 thunks into <name>_thunks.so. wrap does it as its last step, for exactly the thunks the wrapper binds; if it fails, the wrapper uses the JIT. The JLCS dialect library is needed for that, and wherever the wrapper runs (Ship a package) |
visibility | "default" | "hidden" (preserved) | "default" | "hidden" narrows the wrapper's API to what the library annotates. Probe first — §2.12. Any other value is a hard error |
Changing flags, defines, include_dirs or visibility correctly invalidates the per-file IR cache — it is keyed on a compile fingerprint as well as source mtime.
[link]
| Key | Type | Default | Notes |
|---|---|---|---|
optimization_level | String | "0" | "0"–"3", "s", "z"; a leading O/o is stripped, so "O2" works. discover() writes "0"; Hub configs use "2" |
enable_lto | Bool | true for language = "c", else false | Whole-module llvmcall payload — see the warning below |
link_libraries | Vector{String} | [] | -l entries |
link_dirs | Vector{String} | [] | -L entries |
fallback | Bool | false | C only. false = link/optimize/assemble in-process on Julia's resident libLLVM, and a failure is a hard error. true = external llvm-link/opt. C++ always uses the external pipeline |
promote_statics | Bool | true | C in-process bucket only. Rename anything a slice may bind by declare but that cannot reach the .so dynamic symbol table to an exported __rb_<lib>_<name>. Required by [wrap.tier1], harmless otherwise |
enable_lto embeds the entire linked module at every llvmcall site. At whole-library scale that can crash Julia's JIT, and it duplicates file-local static state between the embedded bitcode and the .so. Every Hub config sets enable_lto = false. The experimental per-function path is [wrap.tier1], also off by default — Tier 1.
[binary]
| Key | Type | Default | Notes |
|---|---|---|---|
type | String | "shared" | "shared", "static", "executable"; anything else warns and falls back to "shared" |
output_name | String | "" → lib<project>.so (.a when static) | In ingest mode this is forced to the ingested .so's basename |
strip_symbols | Bool | false | Parsed, not consulted |
[wrap]
| Key | Type | Default | Notes |
|---|---|---|---|
language | String | "cpp" | "c", "cpp", "rust" (experimental). Selects generator, toolchain, and the enable_lto default. discover() sets it from the scanned extensions |
enabled | Bool | true | |
module_name | String | "" → CamelCase of the project name | |
use_clang_jl | Bool | true | false skips AST extraction (DWARF-only path) |
shim_headers | Vector{String} | [] | Headers the macro-shim TU includes (preserved) |
exclude_symbols | Vector{String} | [] | Anchored globs for symbols to keep out of the wrapper; see §2.13 (preserved) |
surface_types_only | Bool | false | Emit only types reachable from the wrap surface instead of every type in DWARF; see §2.14 (preserved) |
surface_types_extra | Vector{String} | [] | Anchored globs for types to keep even when unreachable — callback-only structs, constant-only enums. A pattern matching nothing is an error (preserved) |
style | String | "clang" | "clang", "basic", "none"; validated, but not currently dispatched on — the basic symbol-only generator is selected by missing compilation_metadata.json, not by this key |
[wrap.varargs] (preserved)
Typed overloads for variadic functions. Julia type names, variadic arguments only, list-of-lists. See §2.3 for the full rules and the allowed type names.
[wrap.varargs]
lua_pushfstring = [["Cstring"], ["Cint"], ["Cstring", "Cint"]][wrap.macros] (preserved)
One sub-table per macro. ret and args are C type strings; the presence of args selects function-like vs value macro. See §2.2.
[wrap.macros.deflateInit]
ret = "int"
args = ["z_stream*", "int"]
[wrap.macros.ZLIB_VERSION]
ret = "const char*"[wrap.cstring_owned] (preserved)
function name = "deallocator symbol". "free" goes through the wrapper's libc_free, the library's own C runtime. See §2.4.
[platform.<host>] (preserved)
linux, windows or macos. Any section of this file, merged over the base on that host: tables key by key, everything else replaced. Unknown names are a hard error. See §2.15.
[wrap.tier1] (preserved)
C projects only. Routes eligible functions through Base.llvmcall on a per-function bitcode slice instead of ccall.
[wrap.tier1]
enable = true
max_slice_kb = 64
allow_setjmp = false
exclude = ["lua_error"]| Key | Type | Default | Notes |
|---|---|---|---|
enable | Bool | false | Run the slicer over every Tier-1 candidate and emit Base.llvmcall for the accepted ones |
max_slice_kb | Int | 64 | A tripwire, not a tuning knob — declarations-only slices are kilobyte-sized regardless of function size, so a hit means something unexpected got embedded |
allow_setjmp | Bool | false | Allow slices whose own body calls setjmp/longjmp or a returns_twice callee. Direct-reach only: setjmp buried in a .so-side callee never trips the gate, and does not need to — longjmp across the calling Julia frame is identical exposure for ccall and llvmcall. Ignored on Windows, with a warning: a longjmp back into a JIT'd slice frame ends the process there (STATUSBADSTACK) |
exclude | Vector{String} | [] | Names (mangled or plain) that stay on ccall unconditionally |
Requires [link] promote_statics = true (the default) and the in-process C pipeline. On a fallback or ingest build there is no promoted module, so Tier 1 disables itself for the whole wrap with a warning rather than shipping unverified slices. It does the same if the Slicer fails on the promoted module as a whole. If the Slicer throws while slicing one function, only that function stays on ccall, and a warning names it: that is a Slicer bug, never a failed wrap. Every non-accepted function emits exactly the ccall it would have emitted with Tier 1 off, and the module exports TIER1_FUNCTIONS::Set{String}.
The wrap prints two counts — N slices accepted then N slices emitted (M functions). The first comes with a breakdown of the rest: refused (by policy), errored (the Slicer threw), hazard-gated, oversize, and unresolved-symbol. A gap between the two counts is the ABI shape gate refusing a Cstring or struct crossing, not a failure.
[types]
| Key | Type | Default | Notes |
|---|---|---|---|
strictness | String | "warn" | "strict", "warn", "permissive"; any unrecognised value silently becomes "warn" |
allow_unknown_structs | Bool | true | Opaque pointers for unknown structs instead of failing |
allow_unknown_enums | Bool | false | Map unknown enums to Int32 |
allow_function_pointers | Bool | true | Ptr{Cvoid} for function pointers |
custom | Table{String→String} | {} | Custom type mappings, e.g. MyHandle = "Ptr{Cvoid}"; merged into the type registry |
templates | Vector{String} | [] | C++ instantiations to force-emit (preserved) — §2.1 |
template_headers | Vector{String} | [] | Headers for the instantiation stub (preserved) — §2.1 |
Strictness modes: strict fails on any type that cannot be mapped exactly; warn reports the imperfect mapping and continues (Ptr{Cvoid} for complex pointers); permissive falls back silently. This governs type mapping; it does not relax the exact-or-opaque rule for struct layouts, which is unconditional.
[dependencies]
Each dependency is a named sub-table. Both spellings work:
[dependencies.cjson]
type = "git"
url = "https://github.com/DaveGamble/cJSON.git"
tag = "v1.7.18"
commit = "acc76239bee01d8e9c858ae2cab296704e52d916"
exclude = ["test", "fuzzing"]
[dependencies]
pugixml = { type = "git", url = "https://github.com/zeux/pugixml.git", tag = "v1.15" }| Key | Type | Default | Notes |
|---|---|---|---|
type | String | "local" | "git", "local", "system" — note the default is local, not git; a [dependencies.x] with a url but no type will not clone |
url | String | "" | Required for type = "git" |
tag | String | "" (default branch) | Tag or branch; a tag makes the clone shallow |
commit | String | "" | Expected 40-hex object name. Validated at load; mismatch after checkout is a hard error — §2.11 |
path | String | "" | Required for type = "local"; scanned in place, never copied |
pkg_config | String | "" | For type = "system"; runs pkg-config --cflags |
exclude | Vector{String} | [] | Paths to skip during source injection — §2.6 |
Git dependencies clone into .replibuild_cache/deps/<name>/ and re-checkout on tag change. Resolved sources merge into the compilation graph before [compile] runs, so they are compiled with the same toolchain and flags as your own files — which is exactly why the resulting DWARF is consistent across the whole project.
[ingest]
Experimental, C only. The presence of this section is the mode switch: no compilation happens, only DWARF extraction and wrapper generation over a .so you built yourself (with -g). Ingested libraries dispatch through Tier 3 (ccall) exclusively — no LTO bitcode, no Tier-1 slices, no Tier-2 thunks, and the C++ API surface of an ingested binary is unsupported.
| Key | Type | Default | Notes |
|---|---|---|---|
library | String | — (required) | Path to the pre-built .so; relative paths resolve against the TOML's directory. Missing → the section is ignored with a warning and you silently get source-build mode |
headers | Vector{String} | [] | Header search dirs for type extraction and the noexcept scan; merged into the include dirs |
extra_link_libs | Vector{String} | [] | Libraries the wrapper opens before the main one, for symbols the .so needs but does not record. A bare name follows the -l convention ("m" → libm); prefer a full soname or a path. On glibc ≥ 2.34 m, pthread, dl and rt are part of libc, there is nothing to open, and the wrapper warns. Ingest-only — in a source build use [link] link_libraries |
Generate it with RepliBuild.ingest("/path/to/libfoo.so", headers=[...]) rather than by hand.
[llvm]
| Key | Type | Default | Notes |
|---|---|---|---|
toolchain | String | "auto" | "auto", "system", "jll"; anything else warns and falls back to "auto" |
version | String | "" (auto-detect) | Relevant to the C++ bucket; the C bucket uses the bundled JLL clang and Julia's own libLLVM |
[cache]
| Key | Type | Default | Notes |
|---|---|---|---|
enabled | Bool | true | Skip recompiling unchanged files |
directory | String | ".replibuild_cache" | The real cache path — this key, not [paths] cache |
[workflow]
| Key | Type | Default |
|---|---|---|
stages | Vector{String} | ["discover", "compile", "link", "binary", "wrap"] |
Validated against the five known stage names (an unknown one is a config validation error), but not currently consulted — the pipeline runs its fixed order. Do not use it to skip a stage; call the individual verbs instead.
4. Interactions and precedence
wrap.languagedecides more than the generator. It selects the toolchain (JLLclang+ in-process libLLVM forc; systemclang+++ the external pipeline forcpp) and the default value of[link] enable_lto. Setting it wrong produces a build that works and a wrapper that is missing the whole reason you chose the other one.[wrap.tier1]requires[link] promote_staticsand the in-process C path.fallback = true, ingest mode, or a C++ project all disable Tier 1 for the whole wrap, loudly.enable_ltoand[wrap.tier1]are independent knobs and both can emit. Slicing did not replace the whole-module call-site path.[ingest]wins over source-build keys. A config with both gets warnings naming[compile] source_filesand anygit/localdependency, and proceeds in ingest mode.[binary] output_nameis overridden in ingest mode when left empty, sowrap()finds the library where it expects it.[cache] directoryis the cache path;[paths] cacheis inert. Likewise[paths] sourceand[paths] include.
5. What fails loudly, what warns, what is ignored
Knowing which is which is the difference between a five-minute fix and an afternoon.
Hard error at config load
[dependencies.<n>] committhat is not a full 40-hex object name.[wrap.varargs.<f>]that is not a list, or whose entries are not lists — with a message showing the correct shape.validate_config!: emptyproject.name; non-existentproject.root; a[compile] source_filesentry that does not exist on disk; anoptimization_leveloutside0/1/2/3/s/z; an unknown[workflow] stagesentry; an invalidbinary.type/wrap.style/llvm.toolchain/types.strictnessvalue reaching validation.[compile] visibilityset to anything but"default"or"hidden", or set to"hidden"while[compile] flagscarries a-fvisibility=asking for something else — which of the two decides the library's API is not guessable (§2.12).
Hard error later in the pipeline
- A resolved dependency HEAD that disagrees with the declared
commit. - A built library whose wrappable functions are not reachable in its dynamic symbol table, or whose wrappable surface is empty — the
visibility = "hidden"hazard in §2.12. Checked before the build's cache marker is written, so a refused build does not read as cached-and-fine on the next run. - A macro shim
#includethat resolved outside the project/dependency tree. - A vararg overload naming a type outside the allowed set (§2.3).
- A wrapper whose
ccallsignatures would name an undeclared type — refused before it is written. - A generated wrapper containing an unqualified
error("…")call — refused, because a library that defines its ownerrorsymbol would rebind it and take out every failure path in the module.
Warning, then proceeds
- Missing
[project]section orproject.name. - An unparseable
uuid(a new one is generated). [ingest]withoutlibrary→ the section is dropped and you are in source-build mode.[ingest]alongsidesource_filesor fetchable dependencies.- Invalid
binary.type/wrap.style/llvm.toolchain→ default substituted. - A
[wrap.cstring_owned]value that is not a non-empty string → entry ignored. - A struct whose layout cannot be modelled consistently → degrades to a correctly-sized opaque region (capped at ten warnings plus a summary).
Accepted and ignored
[discovery](whole section),[workflow] stages,[wrap] style,[binary] strip_symbols,[paths] source/include/cache.extra_link_libsoutside[ingest].- Any key not in the schema — TOML parses it, the config drops it. There is no unknown-key warning, so a typo'd key name is silent. Check your spelling against §3 when a setting appears to have no effect.
6. Worked configurations
A C library from git
[project]
name = "lua"
version = "5.4.7"
root = "."
[dependencies.lua]
type = "git"
url = "https://github.com/lua/lua.git"
tag = "v5.4.7"
commit = "1ab3208a1fceb12fca8f24ba57d6e13c5bff15e3"
exclude = ["onelua.c", "lua.c", "luac.c", "ltests.c"] # the interpreter, not the library
[compile]
flags = ["-O1", "-fPIC", "-DLUA_USE_LINUX"]
[link]
enable_lto = false # whole-module payload stays off
optimization_level = "2"
[binary]
type = "shared"
[wrap]
language = "c"
shim_headers = ["lua.h", "lauxlib.h", "lualib.h"]
[wrap.tier1]
enable = false
[wrap.varargs]
lua_pushfstring = [["Cstring"], ["Cint"], ["Cdouble"], ["Cstring", "Cint"]]
[wrap.macros.lua_pop]
ret = "void"
args = ["lua_State*", "int"]
[wrap.macros.LUA_REGISTRYINDEX]
ret = "int"
[types]
strictness = "warn"
allow_unknown_structs = true
allow_function_pointers = true
[cache]
enabled = trueA C++ library with templates
[project]
name = "fmt"
version = "12.1.0"
root = "."
[dependencies.fmt]
type = "git"
url = "https://github.com/fmtlib/fmt.git"
tag = "12.1.0"
commit = "407c905e45ad75fc29bf0f9bb7c5c2fd3475976f"
exclude = ["test", "doc", "support", "benchmark"]
[compile]
flags = ["-O2", "-fPIC", "-std=c++20"]
[link]
enable_lto = false # C++ default; Tier 2/3
optimization_level = "2"
[wrap]
language = "cpp"
[types]
strictness = "warn"
templates = ["fmt::basic_memory_buffer<char>"]
template_headers = ["<fmt/format.h>"]
[cache]
enabled = trueA pre-built C binary (ingest)
[project]
name = "foo"
[ingest]
library = "/path/to/libfoo.so" # must have been built with -g
headers = ["/path/to/include"]
extra_link_libs = ["libbar.so.2"] # a dependency libfoo.so does not record
[wrap]
language = "c"7. Troubleshooting
Symptom → key, including build and cache failures, lives on Troubleshooting. The rows that point at this file:
| What you see | Section on this page |
|---|---|
Template type missing / members Any | §2.1 |
| Header struct has no fields | §2.5 |
| Macro API absent, or wrong values | §2.2 |
| Variadic function only takes fixed args | §2.3 |
| Memory grows on string-returning calls | §2.4 |
| Tests/examples leaked into the wrapper | §2.6 |
| Wrapper exports the library's internal helpers | §2.12 |
| Build refused: "wrappable surface … is EMPTY" / "NOT reachable" | §2.12 — visibility = "hidden" on a library that does not annotate |
Missing header / missing -D | §2.7, §2.9 |
| Setting does nothing | §5 — typos are silent |