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 keys

Discovery 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:

SectionKey
[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 declareSymptom if you don't
is C++ and you want STL/template types[types] templates + template_headersThe type never appears in DWARF; members typed Any, methods missing
exposes API through preprocessor macros[wrap.macros.*] + [wrap] shim_headersThe 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_symbolsThousands 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_onlyEvery 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] flagsStructs wrap to empty Julia structs
ships tests/examples/backends in the same repoexclude on the dependencyUnrelated sources compile in and pollute the DWARF
keeps headers somewhere other than <root>, <root>/include, <root>/src[compile] include_dirsCompile failure, or a TU silently missing declarations
needs -lm, -lpthread, -ldl[link] link_librariesLoads fine; Tier-1 slices demote, or a symbol is missing at first use
is configured by its build system through -D[compile] flags / [compile] definesOne 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 = trueEverything stays Tier 3 ccall (correct, just not inlined)
must be reproduciblecommit on the dependencyA 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>"]
  • templates entries are C++ type spellings, exactly as you would write them in template class X;.
  • Common STL headers are auto-detected from the type name (std::vector → <vector>, std::map → <map>, and the same for unordered_map, set, unordered_set, deque, list, basic_string/string). template_headers is 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 for wstring / 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.

This section is preserved but not derived

[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"
  • ret and args are 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 args key is the switch, not its contents: args = [] means a zero-argument function-like macro (MACRO()), while omitting args entirely means a value macro (MACRO).
  • Shims are emitted with __attribute__((used, visibility("default"))) so they survive -fvisibility=hidden builds and LTO internalization — without that, projects like box2d3 would silently lose every macro.
  • In Julia the replibuild_shim_ prefix is stripped: you call lua_pop(L, 1).
The shim's `#include` must resolve inside your tree

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:

  1. List only the variadic arguments. The fixed parameters (lua_State*, the const char* fmt) come from DWARF and are prepended automatically.
  2. 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 sized Int*/UInt*/Float* types, and Ptr{Name}. Anything else is a hard error naming the entry.
  3. It is a list of lists. f = ["Cint"] is one overload taking… nothing coherent; the parser rejects it and tells you to write f = [["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.

The policy does not depend on the dispatch tier

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.

[link]
link_libraries = ["m", "pthread", "dl"]   # → -lm -lpthread -ldl
link_dirs      = ["/opt/thing/lib"]       # → -L/opt/thing/lib

A 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 = [].

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 = false
Experimental — off by default, not a supported path

enable 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"
Probe before you set this. On a library that does not annotate, it erases the API.

-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          : 27

The 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=hidden in [compile] flags is 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" while flags asks 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=hidden when 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_API expands to a visibility attribute only under -DGGML_SHARED, and without it the flag hid llama_decode along 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 below

On, 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 its MD_*_DETAIL struct as void *. 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 int never names its error enum, so argon2's Argon2_ErrorCodes is 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]

KeyTypeDefaultNotes
nameStringdirectory basenameDrives lib<name>.so and the module name
rootStringdirectory containing the TOMLAbsolute or relative
uuidStringfreshly generatedAn unparseable UUID warns and is regenerated. Keep the one discover() wrote — an absent uuid means a new one on every load
versionString—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]

KeyTypeDefaultNotes
outputString"julia"Wrapper + .so + compilation_metadata.json land here
buildString"build"Intermediate IR and objects
sourceString"src"Parsed, not consulted — sources come from discovery or [compile] source_files
includeString"include"Parsed, not consulted — include roots come from discovery or [compile] include_dirs
cacheString".replibuild_cache"Parsed, not consulted — the cache directory is [cache] directory

[discovery]

KeyTypeDefault
enabledBooltrue
walk_dependenciesBooltrue
max_depthInt10
ignore_patternsVector{String}["build", ".git", ".cache"]
parse_astBooltrue
Note

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]

KeyTypeDefaultNotes
flagsVector{String}["-std=c++17", "-fPIC"]discover() writes ["-fPIC"] for a C-only scan
definesTable{String→String}{}Emitted as -Dkey=value
parallelBooltrueMulti-threaded per-file compilation
source_filesVector{String}[] (auto-discovered)Explicit list; relative entries resolve against root (§2.16); validated to exist at load
include_dirsVector{String}[] (auto-discovered)Passed as -I; relative entries resolve against root (§2.16)
aot_thunksBoolfalsePre-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]

KeyTypeDefaultNotes
optimization_levelString"0""0"–"3", "s", "z"; a leading O/o is stripped, so "O2" works. discover() writes "0"; Hub configs use "2"
enable_ltoBooltrue for language = "c", else falseWhole-module llvmcall payload — see the warning below
link_librariesVector{String}[]-l entries
link_dirsVector{String}[]-L entries
fallbackBoolfalseC 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_staticsBooltrueC 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
Leave `enable_lto = false`

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]

KeyTypeDefaultNotes
typeString"shared""shared", "static", "executable"; anything else warns and falls back to "shared"
output_nameString"" → lib<project>.so (.a when static)In ingest mode this is forced to the ingested .so's basename
strip_symbolsBoolfalseParsed, not consulted

[wrap]

KeyTypeDefaultNotes
languageString"cpp""c", "cpp", "rust" (experimental). Selects generator, toolchain, and the enable_lto default. discover() sets it from the scanned extensions
enabledBooltrue
module_nameString"" → CamelCase of the project name
use_clang_jlBooltruefalse skips AST extraction (DWARF-only path)
shim_headersVector{String}[]Headers the macro-shim TU includes (preserved)
exclude_symbolsVector{String}[]Anchored globs for symbols to keep out of the wrapper; see §2.13 (preserved)
surface_types_onlyBoolfalseEmit only types reachable from the wrap surface instead of every type in DWARF; see §2.14 (preserved)
surface_types_extraVector{String}[]Anchored globs for types to keep even when unreachable — callback-only structs, constant-only enums. A pattern matching nothing is an error (preserved)
styleString"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"]
KeyTypeDefaultNotes
enableBoolfalseRun the slicer over every Tier-1 candidate and emit Base.llvmcall for the accepted ones
max_slice_kbInt64A tripwire, not a tuning knob — declarations-only slices are kilobyte-sized regardless of function size, so a hit means something unexpected got embedded
allow_setjmpBoolfalseAllow 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)
excludeVector{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]

KeyTypeDefaultNotes
strictnessString"warn""strict", "warn", "permissive"; any unrecognised value silently becomes "warn"
allow_unknown_structsBooltrueOpaque pointers for unknown structs instead of failing
allow_unknown_enumsBoolfalseMap unknown enums to Int32
allow_function_pointersBooltruePtr{Cvoid} for function pointers
customTable{String→String}{}Custom type mappings, e.g. MyHandle = "Ptr{Cvoid}"; merged into the type registry
templatesVector{String}[]C++ instantiations to force-emit (preserved) — §2.1
template_headersVector{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" }
KeyTypeDefaultNotes
typeString"local""git", "local", "system" — note the default is local, not git; a [dependencies.x] with a url but no type will not clone
urlString""Required for type = "git"
tagString"" (default branch)Tag or branch; a tag makes the clone shallow
commitString""Expected 40-hex object name. Validated at load; mismatch after checkout is a hard error — §2.11
pathString""Required for type = "local"; scanned in place, never copied
pkg_configString""For type = "system"; runs pkg-config --cflags
excludeVector{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.

KeyTypeDefaultNotes
libraryString— (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
headersVector{String}[]Header search dirs for type extraction and the noexcept scan; merged into the include dirs
extra_link_libsVector{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]

KeyTypeDefaultNotes
toolchainString"auto""auto", "system", "jll"; anything else warns and falls back to "auto"
versionString"" (auto-detect)Relevant to the C++ bucket; the C bucket uses the bundled JLL clang and Julia's own libLLVM

[cache]

KeyTypeDefaultNotes
enabledBooltrueSkip recompiling unchanged files
directoryString".replibuild_cache"The real cache path — this key, not [paths] cache

[workflow]

KeyTypeDefault
stagesVector{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.language decides more than the generator. It selects the toolchain (JLL clang + in-process libLLVM for c; system clang++ + the external pipeline for cpp) 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_statics and the in-process C path. fallback = true, ingest mode, or a C++ project all disable Tier 1 for the whole wrap, loudly.
  • enable_lto and [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_files and any git/local dependency, and proceeds in ingest mode.
  • [binary] output_name is overridden in ingest mode when left empty, so wrap() finds the library where it expects it.
  • [cache] directory is the cache path; [paths] cache is inert. Likewise [paths] source and [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>] commit that 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!: empty project.name; non-existent project.root; a [compile] source_files entry that does not exist on disk; an optimization_level outside 0/1/2/3/s/z; an unknown [workflow] stages entry; an invalid binary.type / wrap.style / llvm.toolchain / types.strictness value reaching validation.
  • [compile] visibility set to anything but "default" or "hidden", or set to "hidden" while [compile] flags carries 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 #include that resolved outside the project/dependency tree.
  • A vararg overload naming a type outside the allowed set (§2.3).
  • A wrapper whose ccall signatures 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 own error symbol would rebind it and take out every failure path in the module.

Warning, then proceeds

  • Missing [project] section or project.name.
  • An unparseable uuid (a new one is generated).
  • [ingest] without library → the section is dropped and you are in source-build mode.
  • [ingest] alongside source_files or 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_libs outside [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 = true

A 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 = true

A 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 seeSection 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