This document helps AI coding agents work effectively with the ffi-converter project. For the user-facing description of the tool, see README.md.
ffi-converter generates Go bindings for C libraries using libffi (no CGo). It parses C headers and outputs Go code.
Prime directive. The Go side is what matters: the correctness of this repo's
source and the correctness and compilability of the code it emits. Exotic C
parsing is not the goal. A construct that cannot be modelled faithfully is
skipped whole and reported in Header.Diagnostics (parser) or
Generator.Diagnostics() (generator), which main.go prints to stderr as
warning: lines. A mis-parsed construct is a bug; a diagnosed skip is correct
behaviour. Never "emit something plausible".
Do not repeat code. One source of truth per concept, table-driven tests.
.
├── main.go # CLI entry point, flag parsing, diagnostics
├── parser/
│ ├── types.go # AST types + typedef resolution
│ ├── lex.go # Tokenizer
│ ├── decl.go # Shared declarator parser
│ └── parser.go # Top-level declaration dispatch
├── generator/
│ ├── ctypes.go # THE C type table (Go type, FFI type, size)
│ ├── names.go # Identifier mangling and the name allocator
│ ├── enums.go # C enum value semantics
│ ├── resolve.go # CType -> Go/FFI mapping, per use context
│ └── generator.go # Planning and emission
├── integration_test.go # Compiles the generated bindings for every header
├── runtime_test.go # Builds real C shared libs and calls through libffi
├── libname_test.go # -lib derivation from the header path
├── testdata/
│ ├── *.h # Header corpus, one per class of defect
│ ├── csrc/ # C implementations for the runtime test
│ └── out/ # Committed output for calculator.h
└── go.mod # Tool has no non-stdlib dependencies
# Build the tool (binary is ./ffi-converter)
go build .
# Run with a test header
./ffi-converter -header testdata/calculator.h -output testdata/out -package calculator -lib calculator
# Full end-to-end verification
go test ./...testdata/out/ has no go.mod, so cd testdata/out && go build . cannot work —
it fails with "no required module provides package github.com/jupiterrider/ffi".
Do not add one. go test ./... is the real check:
integration_test.goruns the CLI over every header intestdata/, writes each result into a temp module with a pinnedgo.mod/go.sum, and really compiles it against the realgithub.qkg1.top/jupiterrider/ffiandgolang.org/x/sys. It also asserts every emitted file parses withgo/parserand is gofmt-stable.runtime_test.gogoes further for the headers that have a C implementation intestdata/csrc/: it compiles a real shared library with the platform C compiler, generates the bindings, and runs a driver that calls every wrapper through libffi into that C code — plain and under-race. It skips when no C compiler is installed.
When modifying the generator, use these mappings:
| C Type | Go Type | FFI Type |
|---|---|---|
| void | (none) | &ffi.TypeVoid |
| bool | bool | &ffi.TypeUint8 |
| char / signed char | int8 | &ffi.TypeSint8 |
| unsigned char / uint8_t | uint8 | &ffi.TypeUint8 |
| short / int16_t | int16 | &ffi.TypeSint16 |
| int / int32_t | int32 | &ffi.TypeSint32 |
| unsigned int / uint32_t | uint32 | &ffi.TypeUint32 |
| long | int64 (+ portability diagnostic) | &ffi.TypeSint64 |
| unsigned long | uint64 (+ portability diagnostic) | &ffi.TypeUint64 |
| long long / int64_t | int64 | &ffi.TypeSint64 |
| unsigned long long / uint64_t | uint64 | &ffi.TypeUint64 |
| size_t / uintptr_t | uint / uintptr | ffiTypeSizeT (chosen at init) |
| ssize_t / intptr_t / ptrdiff_t | int | ffiTypeSSizeT (chosen at init) |
| float | float32 | &ffi.TypeFloat |
| double | float64 | &ffi.TypeDouble |
| long double | (unsupported, diagnosed) | - |
| const char* | string | &ffi.TypePointer |
| char* / unsigned char* (param) | []byte | &ffi.TypePointer |
| char* (return) | string | &ffi.TypePointer |
| char** / const char** | **byte | &ffi.TypePointer |
| T** and deeper (not char**) | *unsafe.Pointer | &ffi.TypePointer |
| void* | unsafe.Pointer | &ffi.TypePointer |
| struct T* (param/return) | *T | &ffi.TypePointer |
| struct by value | StructName | &FFITypeStructName |
| enum | named int type, width per member range | matching &ffi.TypeSint32 / &ffi.TypeUint32 / &ffi.TypeSint64 |
opaque handle (typedef struct X_s* X) |
X (uintptr) | &ffi.TypePointer |
| T[n] as a struct member | [n]T | element type repeated n times via ffiRepeat |
| any pointer in a struct member, any depth | uintptr | &ffi.TypePointer |
| T[n] as a parameter (incl. typedef'd) | decays to the pointer mapping | &ffi.TypePointer |
The builtin part of this table lives in exactly one place in the code:
cTypeTable in generator/ctypes.go. Position-dependent decisions (param vs
return vs struct field, pointer flavour) live in generator/resolve.go. Do not
add a second switch anywhere.
long/unsigned long cannot be represented portably by one static Go type, so
the mapping above is kept and a diagnostic is emitted once per header rather
than pretending it is portable. size_t/ssize_t/uintptr_t/intptr_t/
ptrdiff_t avoid the problem: their libffi type is a generated variable
initialised at init from unsafe.Sizeof.
Enum width is chosen by enumUnderlying in generator/enums.go from the actual
member value range: int64 when the range escapes [MinInt32, MaxUint32],
uint32 when it exceeds MaxInt32 but is non-negative, int32 otherwise. It is
not hardcoded to int32.
long double has no faithful Go representation and is kindUnsupported: any
declaration mentioning it is skipped with a diagnostic.
Out-parameters must stay GC-visible. T** and deeper used to map to
uintptr, which made the generated API impossible to call soundly: the only way
to hand it Go memory was uintptr(unsafe.Pointer(&out)), and that is not a live
reference, so a goroutine stack move during the call left C writing to a stale
address. runtime.KeepAlive does not prevent stack copying. This lost writes
reproducibly, about one run in three. A real Go pointer (**byte,
*unsafe.Pointer) is tracked by the collector and updated by a stack move, so
the pattern is now sound. Do not reintroduce a uintptr out-parameter.
The struct-field exception runs the other way: a Go struct that mirrors C memory
must not hold GC-visible pointers, so every pointer field is uintptr at any
depth. ctx == ctxField is checked first in resolvePointer for exactly that
reason.
Always use ffi.Arg and cast afterward:
var result ffi.Arg // NOT int32
myFunc.Call(&result)
return int32(result)The decision is made by the resolved size of the type, so enums and typedef'd scalars are covered too.
Convert through the single generated mustCString helper — it panics on an
embedded NUL rather than quietly handing C a nil pointer — and keep the result
alive across the call:
namePtr := mustCString(name)
myFunc.Call(..., &namePtr)
runtime.KeepAlive(namePtr)[]byte parameters use the companion bytePtr helper (nil for an empty slice)
with the same runtime.KeepAlive. Both helpers are emitted at most once per
package, only when something actually needs them; the import set is computed
from what was emitted, so there are never unused imports.
var resultPtr *byte
myFunc.Call(&resultPtr)
if resultPtr == nil {
return ""
}
return unix.BytePtrToString(resultPtr)Pass struct pointer directly (not wrapped in unsafe.Pointer):
myFunc.Call(..., &config) // config is a structlibffi requires the rvalue buffer be no smaller than the system register size.
ffi.Arg covers integers, bools and enums; struct-by-value and float32
returns cannot use it, so they are received into a padded wrapper instead of a
bare variable:
var result struct {
Val T
_ uint64
}
myFunc.Call(&result.Val)
return result.ValThe address handed to Call is still &result.Val, so libffi writes at the
start of a buffer that is guaranteed large enough. Do not "simplify" this back
to var result T.
After making changes:
go build ./...go vet ./...gofmt -l .(must print nothing)go test ./...(integration test compiles the generated bindings for every header; runtime test calls real C through libffi)- Regenerate the committed sample and check it in:
go run . -header testdata/calculator.h -output testdata/out -package calculator -lib calculator - Read the
warning:lines the tool prints for the corpus. A new warning means something stopped being supported; a missing warning next to missing output is a bug, because every skip must be reported.
- If the parser cannot see it, extend
parser/decl.go; a construct that cannot be modelled faithfully must be skipped whole with a diagnostic, never half-parsed. - Add ONE row to
cTypeTableingenerator/ctypes.go. There is no second switch to update. - Add a header to
testdata/and a named regression case to the table tests ingenerator/generator_test.go. - Run
go test ./...: the integration test really compiles the generated bindings for every header intestdata/.
The parser is a tokenizer (parser/lex.go) plus a declarator parser
(parser/decl.go), not a C compiler, and there is no preprocessor and no
#include following. It does descend into extern "C" { ... } and
extern "C++" { ... } — the block form, the #ifdef __cplusplus idiom, the
single-declaration form and nesting — so headers that wrap their whole API that
way are read normally, not skipped.
Anything it cannot represent is skipped whole and reported in
Header.Diagnostics, which main.go prints as warning: lines:
- unions
- bitfields
- function-pointer members, and functions returning function pointers
- anonymous nested structs/unions
- multi-dimensional and non-constant-sized arrays
- function definitions (
static inline ...); only declarations translate - file-scope variable declarations
- declarations whose shape comes from a macro, such as zlib's
int foo OF((int a));— resolving that needs a preprocessor
The generator adds its own skips for what the parser saw but cannot be emitted:
variadic functions (libffi needs one CIF per call site), long double, and any
declaration referring to a type that was itself skipped.
Diagnose and skip; never emit something that merely looks plausible. Warnings do
not fail the run — main.go still exits 0 and writes everything it could
translate.
Check that:
- FFI types match Go struct field order exactly
- Pointer vs value semantics are correct
ffi.Argis used for small integer returns
Check the emitted names: every identifier is allocated from one package-wide
nameSet, so a clash shows up as a numeric suffix rather than as broken code.
Names that would be illegal Go are repaired rather than emitted: a C name whose
Go form would start with a digit is prefixed, so _0 becomes the type/function
name X0 and the parameter name x0. Keyword collisions get a trailing
underscore. Nothing should ever reach format.Source as a syntax error.
Legal C, and what a broken include guard produces. Identical redeclarations of
the same function collapse to one wrapper silently. A conflicting
redeclaration keeps the first and emits
warning: skipped a second, conflicting declaration of function "bar": the first one is used. Neither case may emit a phantom second wrapper.
The tool itself has no external dependencies beyond the standard library, so
go.mod has no require block.
Generated code requires:
github.com/jupiterrider/ffi v0.7.0golang.org/x/sys v0.28.0
These versions are pinned in integration_test.go (ffiModule, sysModule,
plus a go.sum literal so the compile checks stay hermetic and offline), and
that is the only place to change them.
Never downgrade ffi below v0.5.1. Earlier releases mirrored ffi_cif as one
32-byte struct on every architecture, but the aarch64 ABI adds
FFI_EXTRA_CIF_FIELDS (aarch64_flags, aarch64_nfixedargs), making the real
ffi_cif 40 bytes. On arm64 ffi_prep_cif wrote 8 bytes past the end of the Go
allocation; once the neighbouring object was reused, ffi_call marshalled from
corrupted state. Symptoms: float/double arguments arriving as zero (a bogus
nfixedargs makes every argument variadic under Apple's ABI) and intermittent
SIGSEGVs. v0.5.1 added cif_arm64.go. If runtime_test.go starts failing with
zeroed floats on darwin/arm64, check this pin first.