Inherited from v1.0.0

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 bnlPlugin
Implement an algorithm that's too slow in interpreted bnlPlugin
Bridge a hardware/OS SDK that has no bnl bindingsPlugin
Ship a reusable module on bpmPlugin
Just call one function from a system libraryPlugin (still — there's no FFI module in v1)

What you need

FileSource
bnl/plugin.hSingle 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 languageWhatever 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

ConstructorReturns
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
InspectorReturns
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

FunctionEffect
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

FunctionEffect
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

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

  1. bnl_value* returned by api->make_* is owned by bnl. It lives until the current native-function call returns. Never free it yourself.
  2. list_push and map_set deep-copy. After attaching a value to a list or map, the original handle is harmless to drop — bnl manages the copy.
  3. 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.
  4. bnl_module* from module_new is owned by bnl. You do not free the module; just return it from bnl_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
1Export exactly one symbol: bnl_load. Use the BNL_EXPORT macro.
2The plugin's only compile-time bnl dependency is bnl/plugin.h. Do not link against bnl.
3All bnl_value* returned by make_* are arena-owned by bnl. Never free them.
4list_push and map_set deep-copy into the parent.
5argv[i] values are valid only during the call.
6Errors: api->throw_error(...), then return 0. No C++ throw, no longjmp.
7bnl is single-threaded. Don't call api->* from a thread other than the one that called your function.
8Plugin DLLs are loaded once per process and never unloaded.
9bnl_load is called exactly once per plugin per process. Register everything inside it.
10Check api->version if you care about ABI compatibility. New fields may be appended without bumping the version.
11Module name in module_new should match the import name.
12Don'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:

LanguageBuild modeNotes
Ccl /LD or gcc -sharedSimplest path.
C++cl /LD or g++ -sharedUse extern "C" on bnl_load and any function pointers.
Rustcrate-type = ["cdylib"]extern "C" fn bnl_load(...) + #[no_mangle].
Go-buildmode=c-sharedUse cgo. Requires gcc on Windows (mingw-w64).
Zigbuild-lib -dynamicexport fn bnl_load(...).
Pascal, D, Nim, Crystallanguage-specific shared-library outputAnything with C FFI works.

The same bnl/plugin.h and the same compiled binary semantics apply to all of them.