A C ABI over the trace engine, split into the same two stages as the CLI:
- Index —
trace_indexruns the whole pipeline (discover → preprocess → parse/lower → merge → solve) against a project directory and writes a SQLite database. - Inspect — open the database and query functions/symbols by source position, list call edges, and traverse bounded call / value-flow graphs.
The header is crates/trace-capi/include/trace.h. The library builds as a
cdylib (libtrace_capi.so / .dylib / .dll) and a staticlib
(libtrace_capi.a); the Rust crate is also usable directly (rlib).
cargo build -p trace-capi --release
# -> target/release/libtrace_capi.{so,dylib,dll,a}#include <trace.h>
int main(void) {
trace_index_options opts = {0};
opts.size = sizeof(opts);
opts.root = "/path/to/project";
opts.output_db = "/tmp/project.db";
trace_index_result r;
char *err = NULL;
char *warnings = NULL;
if (trace_index_ext(&opts, &r, &warnings, &err) != TRACE_OK) {
fprintf(stderr, "index failed: %s\n", err);
trace_string_free(err);
return 1;
}
if (warnings) { /* non-fatal diagnostics, e.g. out-of-tree includes */
fprintf(stderr, "warning: %s\n", warnings);
trace_string_free(warnings);
}
printf("%llu functions indexed\n", (unsigned long long)r.functions);
trace_db *db = trace_db_open("/tmp/project.db", &err);
trace_function_list fns = {0};
/* find the function containing main.c:8 */
if (trace_db_find_functions(db, "main.c", 8, &fns, &err) == TRACE_OK) {
for (size_t i = 0; i < fns.count; i++)
printf("fn %lld: %s (%s)\n", (long long)fns.items[i].id,
fns.items[i].name, fns.items[i].path);
trace_function_list_free(&fns);
} else if (err) {
fprintf(stderr, "%s\n", err);
trace_string_free(err);
}
trace_db_close(db);
return 0;
}Full featured example: crates/trace-capi/examples/ctrace.c (a small CLI with
analyze / functions / symbols / calls / callgraph / dataflow / callchain
subcommands).
The tricky part of any C API is who owns what. The rules here:
| Item | Owner | Lifetime |
|---|---|---|
trace_db handle |
caller | until trace_db_close |
char **out_err / char **out_warnings payloads |
caller | free with trace_string_free |
Strings inside a result (name, path, label, detail, …) |
the result object | until the matching trace_*_free |
items arrays in a result |
the result object | freed by the same trace_*_free |
Inputs (const char *, trace_index_options, trace_symbol[]) |
caller, borrowed | copied during the call |
One arena per query result. Every query builds a result object that owns
an append-only arena (crates/trace-capi/src/util.rs). All strings of the
result are copied into that arena once; the node/edge/function/symbol arrays
reference them by pointer. Freeing the result frees the arena and therefore
every string — C never frees the strings individually, and there is no
query-to-query invalidation. A pointer you grabbed yesterday is still valid
today, as long as you have not freed the result it came from.
On the Rust side this is memory-safe by construction: each CString is heap
allocated once and its buffer never moves, so pointers returned by
Arena::add are stable for the arena's life. Reallocation of the arena's
string Vec moves the CString values but not the buffers they own.
- No panic escapes. Every entry point runs inside
std::panic::catch_unwind; a panic surfaces asTRACE_ERR_PANIC(or aninternal panic:message). Seecrates/trace-capi/src/util.rs(guard). - Borrowed inputs are copied. All C strings and option arrays are copied into Rust-owned memory before the pipeline/query runs, so nothing dangles when the caller's buffers go out of scope.
- Handles are opaque and single-threaded.
TraceDbwraps arusqlite::Connection(!Send) opened withSQLITE_OPEN_READ_ONLY | SQLITE_OPEN_NO_MUTEX. Read-only opens do not create the file: opening a missing or unreadable path fails at open time with an error instead of silently producing an empty database that the first query errors on. Do not share handles across threads; each query is safe only on the thread that owns the handle. - Zeroing on free. Result free functions zero the whole C struct
(
items/count/_implare cleared), so a stale pointer is detectable rather than silently reused; calling a free again on a zeroed struct is a no-op. - Status codes are typed, not text-sniffed. Every entry point returns a
domain error classified at its creation site (
crates/trace-capi/src/util.rs,ApiError): bad caller input / ABI mismatch →TRACE_ERR_INVALID_ARG, filesystem problems →TRACE_ERR_IO, an absent queried entity →TRACE_ERR_NOT_FOUND, pipeline/query failures →TRACE_ERR_ANALYSIS, and a caught panic →TRACE_ERR_PANIC.trace_indexpreflights the output database path (preflight_output,index.rs) and reports a bad output path asTRACE_ERR_IObefore running the pipeline; the probe exactly mirrors the exporter, so it creates a missing parent directory (like the CLI does) and never leaves a stale 0-byte file atoutput_dbon a later failure. - Warnings are a separate channel.
trace_index_extreports non-fatal diagnostics through the optionalout_warningsargument instead of failing: on success it holds a heap message (free withtrace_string_free) when the run produced any, else NULL. The include paths that canonicalize outside the analyzed tree — the "twin headers" hazard the CLI warns about — are surfaced here, so embedders see the same diagnostic-Imisuse the CLI prints. The 0.1trace_indexremains the frozen no-warnings entry point; new callers that want the diagnostics usetrace_index_ext. - Strict argument validation. Everything that is a program error is
rejected at the boundary with
TRACE_ERR_INVALID_ARGinstead of being coerced or deferred:trace_directionis accepted as a rawi32(C enums are ints; the Rust enum can't represent out-of-band values) and validated: only 0 (DOWN) and 1 (UP) are accepted, anything else returnsTRACE_ERR_INVALID_ARG. This matters because direction is a behavior switch, not data: coercing an unknown value to one direction would silently return wrong results asTRACE_OK. Seecheck_dirininspect.rs.depth == 0,n_roots == 0, emptyfilefilters and null handles all returnTRACE_ERR_INVALID_ARG.trace_symbol.kindis a plainint32_t(not the C enum), so out-of-band discriminants written by a caller are ordinary integers rather than undefined behavior when the struct is read back intotrace_db_dataflow. An invalidkindon a dataflow root is still rejected withTRACE_ERR_INVALID_ARG(seecheck_symbol_kindininspect.rs).
- ABI guard on
trace_index_options.opts->sizemust be at leastsizeof(trace_index_options); smaller values are rejected withTRACE_ERR_INVALID_ARG(forward compatible: larger structs are accepted).size == 0opts out of the check for easy zero-init callers. *out_erris cleared on entry. Every function nulls*out_errbefore doing anything, and writes it only on failure. A caller that reuses one buffer across calls can never observe a stale (already-freed) pointer; the rule is: check the status, thenif (*out_err) { free it }.
TRACE_OK (0) on success. Errors: TRACE_ERR_INVALID_ARG,
TRACE_ERR_IO, TRACE_ERR_ANALYSIS, TRACE_ERR_NOT_FOUND,
TRACE_ERR_PANIC. When a call fails and out_err is non-null, *out_err
holds a heap message the caller frees with trace_string_free.
Each code is produced by the specific path that classifies it in ApiError
(crates/trace-capi/src/util.rs), never by message inspection:
TRACE_ERR_INVALID_ARG— null/overshort pointers, malformed arrays, invalidtrace_direction, invalidtrace_symbol.kind,depth == 0,n_roots == 0, out-of-bandtrace_index_options.size.TRACE_ERR_IO— filesystem problems: output-path issues caught by thetrace_indexpreflight, export I/O failures from SQLite/std::io(theFrom<anyhow::Error>adapter downcasts the chain and maps filesystem and SQLite I/O codes toTRACE_ERR_IO), and database open failures intrace_db_open.TRACE_ERR_NOT_FOUND— absent queried entities, e.g. a call-graph root id with no matching function. TheFrom<anyhow::Error>adapter classifies anot found/no value-flow nodemessage from the inspect layer asNOT_FOUND; everything else from the query layer isANALYSIS.TRACE_ERR_ANALYSIS— pipeline/query failures (discovery, parse, export, graph queries).TRACE_ERR_PANIC— a Rust panic caught at the boundary (internal panic: …).
Fields with bounded domains cross the boundary as C enums, not strings:
- edge
resolution—TRACE_RESOLUTION_*(call graphs;TRACE_RESOLUTION_IPCis the no-call-site synthetic edge injected for an IPC proxy/stub pair) - edge
flow_kind—TRACE_FLOW_*(dataflow) - node
kind/loc_kind—TRACE_NODE_*/TRACE_LOC_*(dataflow) - symbol
kind—TRACE_SYM_*(crosses the boundary as a rawint32_t, not the C enum, so invalid values can be validated instead of being read as an out-of-band enum discriminant — see "Strict argument validation")
Every enum has a negative _UNKNOWN sentinel because databases may hold
values this library build does not recognize; C consumers must treat unknown
values as opaque rather than panic. Open-ended text (label, detail,
name, path) stays a string.
TRACE_DIRECTION_* is the one input enum, and it has no sentinel: it is
validated strictly at the boundary (see "Strict argument validation"), so an
out-of-band value is an error instead of silently running in one direction.
- Positions. Function/symbol lookup takes a file-path substring plus
line (and column), mirroring
trace inspect. The best match isitems[0]. - Call edges.
trace_db_call_edges(db, from, to, file, …)lists edges, filtered liketrace calls --from/--to/--file.caller_pathis the caller's own file (not the call-site file), so it stays meaningful for synthetic IPC-bridge edges that have no call site;path/line/colare the actual call site and are empty (0) for synthetic edges. - Call graphs.
trace_db_callgraph(db, root_fn_id, direction, depth)BFS overcall_edges. Nodeids arefunctions.id; nodekindisTRACE_NODE_UNKNOWN(call-graph nodes are functions, not PAG nodes). Edges carryresolutionplus the full call-site path/line/col. - Call chains.
trace_db_call_chains(db, from_fn_id, to_fn_id, direction, depth, limit, out, out_err)finds all paths between two functions bounded bydepthsteps. DirectionTRACE_DIRECTION_DOWNsearches callers->callees;TRACE_DIRECTION_UPsearches callees->callers.limitcaps the number of discovered chains (0 for unlimited). Result is delivered as atrace_graphrepresenting the union graph of all discovered chains; nodeids arefunctions.id, edges carry callresolutionand call-site location. - Dataflow.
trace_db_dataflow(db, symbols, n, direction, depth)BFS overflow_edges. Pass atrace_symbolarray obtained fromtrace_db_find_symbols(itsvar_ididentifies the start variables). Pass the single best candidate (items[0]) to start from one variable, matching the CLI, whose lookup resolves position to one declaration. Nodekind/loc_kinddescribe the PAG nodes; edges carryflow_kind;path/line/colare empty because value-flow edges have no call site. truncatedis set when real neighbors remained beyonddepth.
- Add the
#[repr(C)]type incrates/trace-capi/src/types.rsand the mirroring typedefs/enums ininclude/trace.h. - Implement the query in
crates/trace-capi/src/inspect.rs(result built viaArena+ a leakedVec; free function re-takes theBox). - Cover it with a Rust test in
src/inspect.rs(mod tests) and, when it is consumer-facing C surface, a subcommand inexamples/ctrace.c. - Rebuild the cdylib for the C example in CI (
.github/workflows/ci.yml).
cargo test -p trace-capi # Rust unit tests over the extern ABI
cargo build -p trace-capi # cdylib needed by the C example
# build the C CLI and run it against a fixture (also done in CI):
cc crates/trace-capi/examples/ctrace.c \
-I crates/trace-capi/include \
-L target/debug -ltrace_capi -o target/ctrace
LD_LIBRARY_PATH=target/debug ./target/ctrace analyze tests/fixtures/direct_call -o /tmp/t.db
LD_LIBRARY_PATH=target/debug ./target/ctrace inspect /tmp/t.db callgraph --file main.c --line 1Generate the Doxygen API reference:
doxygen Doxyfile
# -> target/doxygen/html/index.htmlOr from within the crate directory:
cd crates/trace-capi && doxygen
# -> target/doxygen/html/index.html