Releases: m3m0r7/pnl
Release list
v0.7.0
A large step toward 1.0: a holistic schema redesign, a full layered refactor, new runtime/tooling features, and several codegen correctness fixes surfaced by a fresh Alpine Linux sweep.
⚠️ Breaking changes (no backward-compat — still beta)
The pnlx.json / pnl.json schema was redesigned so each key's meaning is clear, separating compile-time options from runtime options. JSON Schemas are frozen and date-versioned under schemas/<name>/2026-07-01/.
pnl.jsonfeatures(runtime):use_functions→global_functions,allow_cdata→cdata_arguments,use_php_scalars_in_params/return/const→scalar_params/scalar_returns/scalar_constants.pnl.jsonconfig:self_repository/packages_repository→release_repository/package_repository;load_paths→library_paths.pnlx.json:requires→native_libraries; newcompile_options{headers, definitions}(was top-levelheaders+require_definitions); newsetup{build_script, install}(wasself_build+installation);installation.<os>.install→setup.install.<os>.commands;checkIfExists→check_if_exists.- lockfile / pathmap:
requires→native_libraries. - CLI flags renamed to match:
--enable-global-functions,--enable-cdata-arguments,--enable-scalar-returns/--enable-scalar-constants.
Features
pnl doctor— diagnoses the toolchain: libclang (required), a C compiler, pkg-config, PHP + the FFI extension, and the workspace; exits non-zero on a required-check failure.ftpsnative-library fetching — viasuppaftpwithnative-tls(Security.framework on macOS, schannel on Windows, vendored OpenSSL on Linux, shared with git2).Pnlx\FFI\Allocator+AllocationScope— allocate inside the extension's own FFI scope and pin allocations against premature GC (the common use-after-free), with a use-after-release guard.- Constraint-aware dependency resolution — a version a dependency pins that conflicts with an already-installed version is now reported, not silently mis-resolved.
Codegen correctness fixes
All four were surfaced by a fresh Alpine Linux (musl) docker sweep and verified there:
- Case-colliding type files. A C struct tag and its typedef differing only in case (e.g.
Argon2_Context/argon2_context) generated two PHP files declaring the same (case-insensitive) class — fatal on a case-sensitive filesystem, while on macOS the field-less wrapper silently won. They are now deduplicated, keeping the field-carrying wrapper. - Quoted-include sibling headers. A library whose API lives in headers included by the entry header (z3's
z3_api.h, hdf5'sH5public.h) was generating a silently empty binding. Headers pulled in via#include "..."are now treated as package-owned. - Typedef names eaten by macro neutralization. An all-uppercase typedef (gdbm's
GDBM_FILE) matched the ABI-attribute-macro heuristic and was stubbed empty, truncating the pointer toint(a segfault at call time). Typedef names are now excluded from neutralization. - Struct-by-value arguments and returns. Structs passed or returned by value (quickjs's
JSValue, gdbm'sdatum) were marshalled as pointers; the generated marshaller now passes and receives them by value.
Refactor & internals
- Full layered module reorganization:
util ← model ← {native, sources} ← codegen ← app(plusembed), with the largest god-files split apart. - Lints codified in
Cargo.toml(-D warnings,clippy::all). - New conventions document at
docs/{en,ja}/conventions.md.
Verification
Alpine 3.21 docker sweep across all packages: 172/180 install, 171/172 installed-package examples pass. The remainder are packages genuinely absent from Alpine repos, plus one macOS-only exported symbol (p11_kit_check_version).
v0.6.0
Features
- Compiled static-inline shims (
compile_options.static_inline). A Cstatic inlinefunction is defined in a header and exports no symbol, so PHP FFI cannot bind it — by default the generated method throws. Opt in (consumer-side, in yourpnl.json) andpnl installnow emits a tinyextern "C"trampoline for each such function, compiles it to a small shared library next to the package, and co-loads it, so the function becomes an ordinary callable method. pnl never reimplements C; the real compiler does the work.- A C compiler is discovered at install time (
$CC, thencc/clang/gcc, honouring$CFLAGS/$LDFLAGS). The C compile is retried as C++ when it fails, so C-API libraries whose headers only compile as C++ (e.g. assimp) are handled. - Opt-in, with graceful degradation. A missing compiler errors only when a package actually has
static inlinefunctions to shim; a shim that fails to compile falls back to throwing stubs (the install still succeeds). Default installs continue to require only libclang.
- A C compiler is discovered at install time (
pnl config <key> [value]. A git-config-style getter/setter forpnl.json, schema-validated (pnl config compile_options.static_inline true,--unsetto reset). After a change it offers to reinstall so generated output reflects it.pnl install --enable-static-inlineis the per-install equivalent.wchar_t *wide strings. Awchar_t *struct field (e.g. hidapi'smanufacturer_string/product_string/serial_number) now generates a?stringgetter that decodes the wide string (UTF-32 → UTF-8) viaUtil::wcStringOrNull, instead of exposing anintpointer.
Notes
- An integrity-digest mismatch now prompts before overwriting when run interactively (default: no), instead of only aborting.
- Verified across the 181-package corpus: 16 libraries build shims (libnotcurses 215 functions, libvips 90, libjasper 86, libpango 72, libhwloc 59, …) with no regressions; the remaining hard cases (transitive includes libclang itself drops, or C++
typeofmacros) fall back to stubs and still install. Documentation (EN + JA) updated.
v0.5.5
Fixes
- Global functions now declare enum return types. A generated global helper
(features.use_functions) for a function returning a Cenumdeclared its return
asint|<wrapper>, but the entity it delegates to returns the generated PHP enum —
so calling it (e.g.\Pnlx\Func\Libsdl\SDL_HasEvent(), which returnsSDL_bool)
threw aTypeError. The global function now returns<Enum>|null, matching the
entity method.
v0.5.4
Extension composition, reworked
This release matures the runtime composition introduced in v0.5.3 into a trait +
attribute model and adds a CLI for it.
Entity model: pure C-function surface
Generated entities now carry only the methods named after C functions. The
method group lives in a generated <Class>LibraryComponent trait the entity mixes
in; all runtime machinery (booting, the loaded library, dispatch) moved to
Pnlx\FFI\NativeLibraryRegistry, and install-time metadata moved from constants to
a #[\Pnlx\Attribute\NativeLibrary] attribute. The only inherited surface is the two
magic methods (__construct, __callStatic), which a C function can never be named —
so composing several Components can only ever clash on real C-function names.
Compose into one shared FFI scope
A composed class mixes in several Components and shares ONE FFI scope, so a CData
returned by one library (e.g. an SDL2_image surface from IMG_Load) flows straight
into another (SDL_CreateTextureFromSurface):
Pnlx\Runtime::compose([A::class, B::class])— runtime: returns a class with
real methods, so by-reference out-parameters round-trip ($x->SDL_QueryTexture($t, $w, $h)).pnl compose <a> <b> --as <Class> [--prefix <p>]— generates a NAMED composite
class file (for editor/static-analysis support), recorded inpnl.jsoncomposites
and wired into the autoload.--prefixresolves method-name collisions via trait
insteadof/as. You can also hand-writenew class extends AbstractExtension { use ACompoent; use BComponent; }— composition needs no special runtime call.
Boot shares the one scope with each member entity, so a pointer wrapped/allocated via
a member's type class is usable by the composite.
use_functions
With features.use_functions, pnl compose also emits composite global functions
under \Pnlx\Func\<Class>\* that delegate to the composite class (sharing its scope;
by-reference out-parameters preserved), retargeted from each member's own
functions.php with --prefix applied to collisions.
Other
- New
compositessection inpnl.json(schemapnl/2026-07-01). build.rsnow fingerprintsschemas/(as it already didsrc/sdk) so a schema
edit always re-embeds into the support library — the runtime no longer validates
against a stale schema.
NOTE: composing two libraries that export the same C symbol shares one global
symbol (the first loaded wins); pnl compose --prefix de-conflicts the PHP names but
warns about this.
v0.5.3
Runtime extension composition
Pnlx\Runtime::compose([Libsdl::class, Sdlimage::class]) fuses several generated
extensions into one shared FFI scope, so a \FFI\CData returned by one package
(e.g. an SDL2_image surface from IMG_Load) can be passed straight into another
package's function (SDL_CreateTextureFromSurface). Previously each extension
booted its own FFI::cdef scope and PHP FFI rejected cross-scope CData.
- Members' generated cdefs are merged (
Pnlx\FFI\CdefComposer) and their libraries
co-loaded into a singleNativeLibrary(NativeLibrary::composite()); each member
class adopts that scope (AbstractExtension::pnlxAdoptNativeLibrary()), so its
generated methods keep marshalling and return-wrapping unchanged — only the native
scope is shared. - Returns a
Pnlx\ComposedScopethat also proxies calls
(Runtime::compose([A, B])->some_function(...)); plainA::fn()/B::fn()static
calls share the scope too.
Code generation
- Pointer-to-const scalars (
const int *,const double *) are now treated as
read-only input arrays instead of writable by-reference out-params — fixes passing
nullwhere PHP cannot pass a literal by reference (libopenal/libswscale). - Structs defined with a body always get a
Types\*wrapper now, even when no
function names them, so a struct handed back throughvoid *(hiredis's
redisReply) can be re-wrapped. - Generated enums gain
toInt()(alias of->value), mirroring the integer-wrapper
API of scalar returns. - Typed wrappers
reinterpret()an incoming\FFI\CDatato their own type.
Native-library resolution
- pkg-config lookup also tries a
lib-prefixed module name (enet->libenet). - Headers living under
include/<pkg>/add theincluderoot to the parse path, so
angle includes carrying the subdir prefix resolve (lzo's<lzo/lzodefs.h>).
Build / runtime
- The embedded PHP SDK (
include_dir!) is rebuilt whenever anysrc/sdkfile
changes, via a build-script content fingerprint — SDK fixes always ship in the
binary instead of silently going stale. Pnlx\Util::isNull()added as a static null-pointer check for generated code.
v0.5.2
v0.5.1
Highlights
Missing libclang now fails fast — before any download — with an actionable message.
libclang is pnl's one hard external requirement for reading C headers, and the
readiness check (pnl -i) plus the platform-specific "how to install it" message
already existed. But during pnl install the check only fired deep inside binding
generation — after the package archive was downloaded, dependencies were installed
recursively, and native libraries and their headers were resolved and fetched. On a
fresh machine without the toolchain you paid for all of that before being told to run
xcode-select --install.
The check now runs up front, so you get the message immediately instead of at the end.
What changed
pnl installpreflights libclang right after reading the package manifest —
before dependency recursion and native-header downloads.pnlx genpreflights right after resolving the library key, before headers are
read.- The message is unchanged and stays platform-specific:
- macOS —
xcode-select --install(the Command Line Tools bundle libclang),
plus aLIBCLANG_PATHhint if they're already installed. - Linux — the distro package to install (
apt install libclang-dev,
dnf install clang-devel,apk add clang-dev,pacman -S clang).
- macOS —
No false positives
The preflight is gated on the effective symbol prefix, so the curated
verbatim-header path (libc and friends, which emit a cdef without libclang) is
never blocked — those packages install with no toolchain at all, exactly as before.
Full diff: v0.5.0...v0.5.1
v0.5.0
Highlights
A type-layer and resolution release: generated PHP now models C structs and
enums as first-class typed values, native-library resolution gained .tbd and
linker-script handling, and the toolchain footprint shrank to libclang only.
Structs & enums in the generated API
- Struct accessors. Each generated
Types\<tag>wrapper exposes typed
getField()/setField()accessors. Structs that embed another struct/union by
value now render too (decided by a render-time fixpoint over the genuinely
emittable aggregates), so a struct passed or returned by value works through
PHP FFI — quickjs'sJSValueround-trips by value end to end. - Enums. A C
enumbecomes an int-backed PHPenum. Parameters accept
Enum|int; a return is mapped back withEnum::tryFrom()(Enum|null).
Fewer external tools — libclang is the only requirement
- The export-symbol filter parses ELF / Mach-O / PE directly (the
object
crate) instead of shelling out tonm. This fixes a real failure on hosts without
binutils, where unexported declarations (SDL's app-providedSDL_main) leaked into
the cdef and brokeFFI::cdef. - Local git-origin lookup goes through libgit2; the only pkg-config use (a test) is
gone. Nonm,git, orpkg-configbinary is needed at install time.
Native-library resolution
- Ordered fallback chain.
library_namesis tried in order; avirtualentry
resolves to a real on-disk file when one exists (so the export filter can run) and
only falls back to a bare-name load when none does. - macOS
.tbdsupport. A newload_type(auto|elf|tbd|dll|dylib) plus a.tbd
parser lets macOS system libraries resolve through the SDK text stub for their
exports while the runtime loads the dylib by itsinstall-namefrom the dyld
shared cache. The active SDK is found viaxcrun/xcode-select. - Linker scripts / split libraries. A GNU ld linker script
(libncurses.so→INPUT(libncurses.so.6 -ltinfo)) is parsed and its inputs are
co-loaded, so a symbol split into a sibling.so(curses_versionin libtinfo)
resolves; a linker-script "exact match" is skipped in favour of the real soname.
Generator robustness
- A qualifier macro (
#define RESTRICT restrict) no longer leaks into a parameter. - An enum's own forward typedef no longer self-references when projected.
- An empty-
symbol_prefix(verbatim header) package still drops declarations the
library does not export (glibc'satexit, inlibc_nonshared.a).
Tooling
tests/docker-sweep/— a committed, build-once, single-foreground-container,
internally-parallelpnl install+EXAMPLES.mdsweep across Alpine and Ubuntu,
with agent rules for interpreting results.
Validation
Swept all packages without binutils on Alpine (musl) and Ubuntu (glibc): every
installable, non-hardware package's example runs, with zero cdef load-time errors
on both. The only remaining example failures are hardware-gated (an HID device, a
serial port). Rust (162) and PHP (65) suites green; clippy, php-cs-fixer, phpstan and
schema checks clean.
v0.4.3
Highlights
Callbacks: pass a PHP callable where a C function pointer is expected.
Until now every function-pointer parameter was generated as an opaque void *, so callback-taking C APIs (comparators, write/visit callbacks, and similar) couldn't actually be driven from PHP — PHP FFI rejects a closure for a void *. They are now generated as real C callback types, so a PHP closure or named callable is passed straight through and PHP FFI builds the C callback trampoline.
What you can do now
// int example_apply(int value, int (*callback)(int));
$result = Example::example_apply(20, static fn (int $v): int => $v * 2); // 41- Generated wrappers type a function-pointer parameter as
callable(thecdatavariant also accepts\FFI\CData). - The callback's arguments and return value are marshalled from the C signature in the cdef — an
intarrives as a PHPint, a pointer arrives as an\FFI\CData. - Both a closure and a plain callable (a function name) are accepted.
Faithful, typed callback signatures
Each component of the callback keeps its real, declared type, so your closure receives something useful:
- A pointer to a type the package already emits (its own
struct/typedef) stays that type — e.g.int (*cb)(const struct point *)— so the closure gets a typed\FFI\CDataand can read fields ($p->x) without a cast. - A
const char *callback argument stays achar *, readable with\FFI::string(). - Scalars keep their exact C width.
- A component degrades to
void *only when its spelling can't be placed in a function-pointer declarator (a nested function pointer, an array, or an anonymous aggregate); the whole parameter falls back tovoid *for a pointer-to-function-pointer or a by-value aggregate. A type referenced only through a callback signature is backfilled, so the cdef still loads.
Scope & caveats (PHP FFI itself)
- Synchronous callbacks — invoked while the C call is on the stack (the common case) — are fully supported.
- A stored / asynchronous callback (kept by C and called after the call returns) inherits PHP FFI's own trampoline-lifetime limitation and is not guaranteed safe.
- Throwing out of a callback is fatal (
Throwing from FFI callbacks is not allowed) — handle errors inside the callback and return a value.
Validation
New unit coverage for callback rendering and the backfill extractor, plus an end-to-end test that invokes both a closure and a named callable through the compiled native fixture. Generated golden snapshots updated. Rust (154) and PHP (62) suites green; clippy, php-cs-fixer, and phpstan clean.
v0.4.2
Highlights
v0.4.2 makes raw PHP scalars the default for generated method arguments, so out of the box you can pass plain int/float/string without wrapping them in \Pnlx\Types\*. It also ships the in-repo agent skills documentation under .agents/.
use_php_scalars_in_params on by default
features.use_php_scalars_in_paramsnow defaults totrue: generated methods accept a raw PHP scalar argument directly, instead of requiring a\Pnlx\Types\*wrapper.- Applied consistently across every layer that resolves the flag:
- Rust manifest serde default (
#[serde(default = "default_true")]) and thePnlFeaturesDefaultimpl. - PHP
Pnlx\Runtime::useScalarsInParams()falls back totruewhen the key is absent frompnl.json, so hand-written manifests that omit the field behave the same as generated ones. - JSON schema (
schemas/pnl/2026-07-01/schema.json) now records"default": true.
- Rust manifest serde default (
- To keep the stricter wrapper-only behavior, set
features.use_php_scalars_in_paramstofalseinpnl.json.
Removed the redundant install flag
- Dropped
pnl install --enable-use-php-scalars-in-paramsand itsInstallOptionsplumbing — the feature is on by default, so opting in is no longer needed. Opting out is done by editingpnl.json.
Agent skills documentation
- Added
.agents/skills/guidance:pnl-usage,pnl-php-sdk, andpnlx-authoringSKILL.mdfiles (surfaced to Claude Code via the.claude/skillssymlink).
Documentation
- Updated
docs/en/configuration.mdanddocs/ja/configuration.mdto describetrueas the default forfeatures.use_php_scalars_in_params.
Validation
- Rust:
cargo build,cargo clippy,cargo fmt --check, andcargo test(152 passed) all clean. - PHP: PHPUnit (61 tests, 276 assertions) and PHPStan level max pass.