C API & extensions

Amber has exactly two seams, and both live in one header. src/ext.h sections 1–5 are the in-process registry: drop a .c file into ext/, rebuild, and it plugs itself in without a line of src/ being patched. Section 6 is the out-of-process API on the front of libamber.so.

The in-process seam — ext/

ext/ is empty in a stock checkout. An out-of-tree package installs itself by dropping .c files there and re-running ./build.sh; they are compiled with the same flags, linked into the same binary, and register themselves from a constructor.

HookWhat it lets an extension do
am_ext_verb("xyz", fn)register a backtick verb — `xyz x — at runtime
am_ext_bsclaim a \-command before the "unknown \cmd is a shell command" fallback
am_ext_hintoffer inline ghost text in the editor — never inserted until accepted
am_ext_completeadd Tab candidates ahead of the built-in lexical sources
am_ext_startuprun once, lazily, when the REPL first reads a line
am_ext_usage / am_ext_bannerappend to --help and to the banner

The Amber-level half goes in lib/; repl.k loads lib/ext.k whole-file at startup if it exists, fully trapped, and an extension may define the optional ext.pre / ext.post / ext.err / ext.raw / ext.tag hooks.

Why this exists

Pulling a new Amber release must never conflict with a package you installed, and a user who installs nothing must pay nothing — every hook is a null pointer and every call site is a predictable branch. tests/ext_probe.c is a complete worked example; tests/test_ext_seam.sh installs it, checks every hook, and uninstalls it again.

amber-ai is installed exactly this way:

git clone https://github.com/BonucciAndrea/amber-ai.git
cd amber-ai && ./install.sh /path/to/amber

The out-of-process seam — libamber.so

./build.sh                 # ./amber          (unchanged; identical binary, zero new cost)
./build.sh --shared        # ./amber  +  libamber.so
./build.sh --shared-only   # libamber.so only
AMBER_SHARED=1 ./build.sh  # same as --shared, for callers that cannot pass a flag
host.c
#include "ext.h"                       /* section 6 -- nothing else from src/ */

amber_init("/path/to/amber");          /* boots the engine, loads the .k stdlib */
amber_value t = amber_eval_qsql("select vwap:wavg[sz;px] by sym from trades");

int type; long long n; int bits;
const void *px = amber_get_vector_ptr(amber_table_column(t, 2), &type, &n, &bits);
/* `px` IS the engine's column payload. Not a copy of it. */

~60 entry points, all named amber_*. Boot and evaluate (amber_init, amber_eval_str, amber_eval_qsql, amber_call), reference counting (amber_retain / amber_release), the zero-copy vector seam (amber_get_vector_ptr), tables and dictionaries, constructors for pushing data back in, the Arrow C Data Interface, rendering, and amber_plugin_load for dlopening a native plugin into a running engine.

What the shared build changes — and what it deliberately does not

The executable is untouched

./build.sh with no flags produces byte-for-byte what it produced before, from the same objects, with the same flags. The shared library is a separate object set (-fPIC -Dshared): position-independent code and the global-dynamic TLS model a dlopen'd library needs both change code generation, and sharing objects between the two would silently pessimise ./amber.

Only amber_* and am_ext_* are exported

Amber's internal C is written in a terse K-derived idiom — the engine's own globals are called mr, su, us, err, run, add, sub, pk, cpl. Those are perfect inside one static binary and actively dangerous inside a library loaded next to NumPy, libarrow and libpython. An export map (src/libamber.map) makes everything else genuinely absent from the dynamic symbol table, so it cannot be bound to by accident and cannot interpose on a host's symbol of the same name.

The library records a SONAME

Two copies of the library means two engines

A satellite that dlopens a second copy of libamber.so gets a second engine: two heaps, two symbol tables, two global namespaces, and values from one that are meaningless to the other — with no error, because nothing is technically wrong. The SONAME is what lets the loader recognise an already-loaded copy and reuse it, so python-amber, libamber_arrow.so and any plugin in one process share one engine.

amber_arrow.library_path() tells you which one you got.

TLS drops to global-dynamic in the shared build only

./amber keeps the initial-exec model on the allocator's hot path. A dlopen'd library cannot: it is resolved out of the static TLS block the loader sizes before main(), and borrowing from glibc's small surplus reserve fails nondeterministically — with cannot allocate memory in static TLS block — depending on what else the host imported first.

Apache Arrow C Data Interface

The engine exports Arrow directly, in about ninety lines (src/ar.c), with no libarrow linkage at all. Export is zero-copy.

amber>
p:arrow.export t                    / table  -> (schemaAddr; arrayAddr)  64-bit C-ABI pointers
arrow.import p                      / (schemaAddr; arrayAddr) -> Amber table

That is exactly the right amount of Arrow for a 10,000-line C runtime to own: it covers "hand me this table" completely. What it does not cover is "hand me this 400-million-row table in batches, and stop when I stop asking" — the ArrowArrayStream — and that lives out in amber-arrow, where it can grow.

Verification

tests/test_capi.sh              # release build, then ASan + UBSan
tests/run_tests.sh --asan       # the whole suite, the C API included

tests/test_capi.c is the only consumer of libamber.so inside the engine repository, and it is written the way a satellite would write it: it includes src/ext.h and nothing else from src/, never dereferences an amber_value, and links the shared library rather than the objects. If it ever needs a second -I, the API is wrong.

78 assertions, clean under ASan + UBSan with leak detection

Because a C API whose ownership rules are only documented is a C API whose ownership rules are wrong, and LeakSanitizer is what checks the prose.