Plugin development
A bnl plugin is a .dll / .so / .dylib shared library that implements bnl's C plugin contract. Plugins extend the runtime — wrap a C library (libcurl, SQLite, OpenCV…), expose a hardware SDK, or implement a fast inner loop in a compiled language. They import into bnl exactly like a standard-library module.
The contract is a single C header, bnl/plugin.h. Drop it into your project, write one C function, compile to a shared library. There is no bnl runtime to link against; everything goes through a function-pointer table bnl hands the plugin at load time.
When to write a plugin
| You want to… | Tool |
|---|---|
| Use a third-party C library (libcurl, OpenSSL, SDL, etc.) from bnl | Plugin |
| Implement an algorithm that's too slow in interpreted bnl | Plugin |
| Bridge a hardware/OS SDK that has no bnl bindings | Plugin |
| Ship a reusable module on bpm | Plugin |
| Just call one function from a system library | Plugin (still — there's no FFI module in v1) |
What you need
| File | Source |
|---|---|
bnl/plugin.h | Single drop-in C header. Get it from a release archive or copy it from the bnl repo's include/bnl/. |
Your plugin source (.c / .cpp / .rs / .go / .zig) | You write it. ~50 lines for a simple module. |
| A compiler for your language | Whatever you choose. No bnl-specific toolchain. |
bnl (to run your plugin) | Already installed. |
You do not need: bnl_core.lib, other bnl headers, a matching bnl version (within the same BNL_PLUGIN_API_VERSION), or a matching MSVC version.
Quickstart
my-plugin/
├── bnl/
│ └── plugin.h ← drop-in header
└── mathx.c ← plugin source
mathx.c — a minimum-viable plugin:
#include <math.h>
#include "bnl/plugin.h"
static bnl_value* cube(const bnl_api* api,
int argc, bnl_value** argv, void* ud) {
(void)argc; (void)ud;
double x = api->get_number(argv[0]);
return api->make_number(api, x * x * x);
}
static bnl_value* hypot_fn(const bnl_api* api,
int argc, bnl_value** argv, void* ud) {
(void)argc; (void)ud;
double a = api->get_number(argv[0]);
double b = api->get_number(argv[1]);
return api->make_number(api, sqrt(a*a + b*b));
}
BNL_EXPORT bnl_module* bnl_load(const bnl_api* api) {
bnl_module* m = api->module_new(api, "mathx");
api->module_add_function(m, "cube", 1, cube, 0);
api->module_add_function(m, "hypot", 2, hypot_fn, 0);
api->module_add_value (m, "greeting",
api->make_string(api, "hi from C", 9));
return m;
}
Compile — one line per OS:
# Windows (MSVC, x64 Developer Command Prompt)
cl /LD /O2 /I. mathx.c
# Linux (gcc)
gcc -shared -fPIC -O2 -I. -o mathx.so mathx.c -lm
# macOS (clang)
clang -shared -O2 -I. -o mathx.dylib mathx.c -lm
Import — drop the .dll/.so/.dylib next to a bnl script:
import "./mathx.dll" as m; // or "./mathx.so" / "./mathx.dylib"
print(m.cube(4)); // 64
print(m.hypot(3, 4)); // 5
print(m.greeting); // hi from C
That's the whole loop. The plugin's only ABI surface is the four exported names (bnl_load, cube, hypot_fn, ...) and the bnl_api function-pointer table it receives.
The bnl_api reference
bnl_load receives a pointer to a bnl_api struct of function pointers. The plugin uses these to construct values, inspect arguments, build the module, and raise errors. Every call goes through the table — there are no direct linker references to bnl.
Values
| Constructor | Returns |
|---|---|
api->make_null(api) | bnl null |
api->make_bool(api, b) | bnl bool (0/1) |
api->make_number(api, n) | bnl number (double) |
api->make_string(api, s, len) | bnl string (byte-counted; not NUL-terminated) |
api->make_list(api) | empty bnl list |
api->make_map(api) | empty bnl map |
| Inspector | Returns |
|---|---|
api->get_type(v) | BNL_TYPE_NULL/BOOL/NUMBER/STRING/LIST/MAP/OTHER |
api->get_bool(v) | int (0/1) |
api->get_number(v) | double |
api->get_string(v, &len) | byte pointer + length (not NUL-terminated) |
Lists
| Function | Effect |
|---|---|
api->list_length(list) | size_t |
api->list_get(api, list, i) | element at i, or NULL if out of range |
api->list_push(list, item) | append (deep-copies into the list) |
Maps
| Function | Effect |
|---|---|
api->map_size(map) | size_t |
api->map_has(map, key) | 0 or 1 |
api->map_get(api, map, key) | value or NULL |
api->map_set(map, key, value) | upsert (deep-copies into the map) |
api->map_key_at(map, i, &key, &len) | iterate keys by index |
Module construction
| Function | Effect |
|---|---|
api->module_new(api, name) | start a module |
api->module_add_function(m, name, arity, fn, ud) | bind a function (arity is the fixed arg count, or -1 for variadic) |
api->module_add_value(m, name, v) | bind a constant (string / number / map / list) |
Errors
api->throw_error(api, "useful message");
return 0; // any further work is undefined
The error surfaces in bnl as a normal exception caught by try / catch:
try {
m.must_be_pos(-1);
} catch (e) {
print(e); // "plugin function 'must_be_pos': must_be_pos: value is negative"
}
Memory ownership
These rules are the most important to internalize:
bnl_value*returned byapi->make_*is owned by bnl. It lives until the current native-function call returns. Never free it yourself.list_pushandmap_setdeep-copy. After attaching a value to a list or map, the original handle is harmless to drop — bnl manages the copy.argv[i]is valid only during the call. Don't stash pointers between calls. If you need to remember a value, copy its contents into your own state.bnl_module*frommodule_newis owned by bnl. You do not free the module; just return it frombnl_load.
If you obey these rules you cannot leak or double-free anything bnl-side.
Errors and exceptions
api->throw_error(api, msg) records an error in the current call's context. The plugin function should then return 0 (NULL). When the call unwinds, bnl raises a runtime exception with your message. Do not use C longjmp, C++ throw, or other unwinding mechanisms — they bypass bnl's error path and may crash the interpreter.
Cross-platform shipping
A plugin built on Linux only loads on Linux; you need one binary per OS. Two common patterns:
Pattern A — multiple archives, one per OS. Each archive contains the matching binary and a bnl.json whose native field names that file:
mathx-1.0.0-windows-x64.zip → bnl.json (native: "mathx.dll") + mathx.dll
mathx-1.0.0-linux-x64.tar.gz → bnl.json (native: "libmathx.so") + libmathx.so
mathx-1.0.0-macos-arm64.tar.gz → bnl.json (native: "libmathx.dylib") + libmathx.dylib
The user downloads the archive matching their OS. bpm follows the same pattern: it picks the correct archive for the host platform at install time.
Pattern B — single fat archive. One archive contains all three binaries plus per-OS bnl.json overrides. Requires runtime per-OS resolution support; not in the v1 contract but planned.
For now, Pattern A is the recommended publishing format.
Installing via bpm
A native plugin installs into a bnl project exactly like a pure-bnl package:
bpm add mathx
bpm fetches the platform archive, extracts it into deps/mathx/, leaving a layout like:
my-app/
├── bnl.json ← project marker
├── main.bnl
└── deps/
└── mathx/
├── bnl.json ← {"name": "mathx", "native": "mathx.dll"}
└── mathx.dll
From main.bnl:
import "mathx" as m;
print(m.cube(4)); // 64
The bnl module resolver walks up from main.bnl's directory, finds deps/mathx/, reads bnl.json, sees the native field, and loads the shared library. Identical user-facing experience whether the dep was hand-placed or installed by bpm.
Rules at a glance
| # | Rule |
|---|---|
| 1 | Export exactly one symbol: bnl_load. Use the BNL_EXPORT macro. |
| 2 | The plugin's only compile-time bnl dependency is bnl/plugin.h. Do not link against bnl. |
| 3 | All bnl_value* returned by make_* are arena-owned by bnl. Never free them. |
| 4 | list_push and map_set deep-copy into the parent. |
| 5 | argv[i] values are valid only during the call. |
| 6 | Errors: api->throw_error(...), then return 0. No C++ throw, no longjmp. |
| 7 | bnl is single-threaded. Don't call api->* from a thread other than the one that called your function. |
| 8 | Plugin DLLs are loaded once per process and never unloaded. |
| 9 | bnl_load is called exactly once per plugin per process. Register everything inside it. |
| 10 | Check api->version if you care about ABI compatibility. New fields may be appended without bumping the version. |
| 11 | Module name in module_new should match the import name. |
| 12 | Don't block the interpreter for long. Slow work goes through libuv or a worker thread (using only your own state). |
Languages other than C
The contract is C ABI, so anything that produces a C-callable shared library works:
| Language | Build mode | Notes |
|---|---|---|
| C | cl /LD or gcc -shared | Simplest path. |
| C++ | cl /LD or g++ -shared | Use extern "C" on bnl_load and any function pointers. |
| Rust | crate-type = ["cdylib"] | extern "C" fn bnl_load(...) + #[no_mangle]. |
| Go | -buildmode=c-shared | Use cgo. Requires gcc on Windows (mingw-w64). |
| Zig | build-lib -dynamic | export fn bnl_load(...). |
| Pascal, D, Nim, Crystal | language-specific shared-library output | Anything with C FFI works. |
The same bnl/plugin.h and the same compiled binary semantics apply to all of them.