Docs/Writing an adapter plugin
Guide

Writing an adapter plugin

Every capability of DominScript is a C plugin. This page says why, and walks through the smallest complete one: what you must fill in, what is optional, and what the runtime does with each part.

hello: 2 verbs, 90 linessource plugins/hello/HelloPlugin.ccopy it and rename the namespace

Why adapters, and not a bigger language

DominScript itself is small on purpose: integers, doubles, strings, blobs, structs bound over blobs, functions, callbacks, and a loop machine that runs hot loops fast. It has no sockets, no files, no crypto, no JSON in the language. Every one of those is an adapter plugin: a shared library that takes a C library (or a few hundred lines of C) and presents it to the script as a namespace of verbs — tcpclient.ReadLine, json.Get, map.PutInt.

The reason is the interface. A plugin sees the same five value kinds the script does (int, double, string, blob, reference) and nothing else: no object model, no GC handles, no marshaling layer. A C function that takes a DominArgument array and fills a DominValue is the whole contract. That keeps three things true at once: the runtime never grows (new capability = new plugin, not new syntax); the plugins are independent of each other and of the interpreter’s internals (a plugin source references nine public headers and nothing else — a test enforces it); and the same table the C compiler sees is what the binder, the loop machine and the documentation generator consume. Fill in the table once, and four things happen.

The whole file

This is plugins/hello/HelloPlugin.c, complete. Two verbs: an integer function and a string function with an error path. Everything below refers back to it.

#include "DominPluginCommon.h"

//============================================================//
// hello -- the smallest complete ADAPTER plugin.
//
// Two verbs, one of each kind a plugin usually needs: a pure integer
// function (hello.Add) and a string-producing function with an error
// path (hello.Greet). Copy this directory, rename the namespace, and
// replace the two functions -- the rest (the table, the traits, the
// export line) stays the same shape for every plugin.
//
// What the host does with each part:
//   - the parameter tables: the BINDER checks every call site at bind
//     time (count, kinds, types), so the C function may trust Args[];
//   - the traits: the LOOP MACHINE may call a pure verb as a machine
//     op inside a fused loop (no PreparedArg, no re-validation);
//   - the host API: errors and notices go back through it, and a
//     string result may be written straight into the host's buffer.
//============================================================//

static DominHostApi gHostApi;

/* hello.Add(a, b) -> a + b : two integers in, one integer out. Nothing
   can fail here -- the host already checked that both arguments are
   integers, so the function is three lines. */
static int Add(DominRuntimeContext *Ctx, int ArgCount, const DominArgument *Args, DominValue *Result)
{
    (void)Ctx;
    (void)ArgCount;
    Result->Type = DOMIN_VALUE_INT;
    Result->As.IntValue = Args[0].As.Value.As.IntValue + Args[1].As.Value.As.IntValue;
    return(DOMIN_STATUS_OK);
}

/* hello.Greet(name) -> "Hello, <name>!" : a string in, a string out, and
   one error path (an empty name). The result buffer comes from the host
   when a loop asks for it, else from malloc -- the host frees it either way. */
static int Greet(DominRuntimeContext *Ctx, int ArgCount, const DominArgument *Args, DominValue *Result)
{
    const char *Name = Args[0].As.Value.As.StringValue;
    size_t Len;
    char *Out;

    (void)Ctx;
    (void)ArgCount;
    if (Name[0] == '\0')
    {
        DominPlugin_SetError(&gHostApi, "hello.Greet: the name is empty.");
        return(DOMIN_STATUS_ERROR);
    }
    Len = 7U + strlen(Name) + 1U;                       /* "Hello, " + name + "!" */
    Out = DominPlugin_ResultString(&gHostApi, Len);     /* the host's buffer inside a fused loop ... */
    if (Out == NULL)
    {
        Out = (char *)malloc(Len + 1U);                 /* ... or our own, everywhere else */
        if (Out == NULL)
        {
            DominPlugin_SetError(&gHostApi, "hello.Greet: out of memory.");
            return(DOMIN_STATUS_ERROR);
        }
    }
    memcpy(Out, "Hello, ", 7U);
    memcpy(Out + 7U, Name, strlen(Name));
    Out[Len - 1U] = '!';
    Out[Len] = '\0';
    Result->Type = DOMIN_VALUE_STRING;
    Result->As.StringValue = Out;
    return(DOMIN_STATUS_OK);
}

/* The parameter tables -- the binder's contract for every call site. */
static const DominParamInfo gAddParams[] =
{
    DOMIN_PLUGIN_VALUE_PARAM("$A", DOMIN_PARAM_TYPE_INT),
    DOMIN_PLUGIN_VALUE_PARAM("$B", DOMIN_PARAM_TYPE_INT)
};

static const DominParamInfo gGreetParams[] =
{
    DOMIN_PLUGIN_VALUE_PARAM("$Name", DOMIN_PARAM_TYPE_STRING)
};

/* The function table: namespace, verb, parameters, C entry. */
static const DominFunctionEntry gFuncs[] =
{
    DOMIN_PLUGIN_FUNCTION_FIXED("hello", "Add",   gAddParams,   Add),
    DOMIN_PLUGIN_FUNCTION_FIXED("hello", "Greet", gGreetParams, Greet)
};

/* The traits, PARALLEL to gFuncs: both verbs are pure (no state, no I/O), so a
   fused loop may run them as machine ops. Leave DOMIN_PLUGIN_TRAITS_NONE for a
   verb that is not -- wrong traits cost speed, never correctness. */
static const DominFunctionTraits gTraits[] =
{
    DOMIN_PLUGIN_TRAITS_PURE_INT,
    DOMIN_PLUGIN_TRAITS_PURE_STRING
};

/* The init / deinit hooks: this plugin opens nothing and frees nothing. */
DOMIN_PLUGIN_ALWAYS_READY()

/* The export: the one symbol the host looks for. */
DOMIN_PLUGIN_DEFINE_EXPORT_WITH_HOST_API_AND_TRAITS("HelloPlugin", gFuncs, gTraits, NULL, gHostApi)

The five mandatory parts

1. The C entry. Every verb is a function of the same shape: int Verb(DominRuntimeContext *Ctx, int ArgCount, const DominArgument *Args, DominValue *Result). It reads its inputs from Args[i].As.Value, writes one output into *Result (type plus payload), and returns DOMIN_STATUS_OK or DOMIN_STATUS_ERROR. Nothing else is required of it. Add is three lines because the host has already checked that both arguments are integers.

2. The parameter table. One DominParamInfo array per verb: a name (for messages) and a type — INT, FLOAT, NUMBER, STRING, BLOB or ANY — and whether it is a value or a ref. This is the binder’s contract: every call site in every script is checked against it before the script runs (count, kinds, types), which is why the C function may trust Args[]. A variadic verb declares a range and one type for all arguments instead (DOMIN_PLUGIN_FUNCTION_VARIADIC, see plugins/example).

3. The function table. One DominFunctionEntry per verb: namespace, verb name, parameter table, C entry. The namespace (hello) is what the script writes before the dot; it should be unique across the plugins a script loads.

4. The export line. One macro at the end of the file. It defines the single symbol the host looks for (Domin_GetPluginExport) and hands over the tables. With nothing else, DOMIN_PLUGIN_DEFINE_EXPORT(name, funcs, NULL) is enough; the variant used above also receives the host API and the traits (next section).

5. The init and deinit hooks (explained with diagrams here). Two functions with fixed names, PluginReady and PluginDeinit, defined before the export line; the export macro wires them, and a plugin without them does not compile. PluginReady(Message, Cap) runs when the host loads the plugin — before the script is bound, before any statement — and returns DOMIN_PLUGIN_READY or DOMIN_PLUGIN_NOT_READY. Not ready means the script does not start: the host prints the plugin’s name and whatever the plugin wrote into Message. An adapter opens its library here and writes the “not found — install it, or copy file into runtime folder” text when that fails (one call, DominRtl_NotFound); when the library has a purely software init of its own, Ready runs it as well (sdl2_image calls IMG_Init here, mysql mysql_library_init) and Deinit runs the matching quit. Device-level initialisation — an SDL subsystem, a GL context — stays with the verb that needs it, because only the script knows which device it will use. The constant decides; the text is independent of it, so a ready plugin may still leave a note, which the host shows as a notice. PluginDeinit(Message, Cap) runs at shutdown, closes what Ready opened, frees what the plugin allocated, and may leave a note the same way. A plugin with nothing to open, free or say writes one line, DOMIN_PLUGIN_ALWAYS_READY(), as above. domin --validate reports a not-ready plugin as a warning instead of refusing, so a script can be checked on a machine that lacks the library.

That is a plugin. Anything a script calls that is not in the table does not exist; anything in the table works from the first plugin "..." line.

The adapter: a plugin that wraps a library

hello above is a plain plugin: it computes everything itself, so its hooks have nothing to open and the one-line DOMIN_PLUGIN_ALWAYS_READY() is the whole story. An adapter is a plugin whose verbs are thin wrappers around a third-party library — SDL2, SQLite, OpenSSL, FFmpeg, or in the skeleton below, the C math library. Three things change, and they are exactly the three places the lifecycle page draws.

[A] The library is loaded at run time, never linked. A candidate list of file names per platform, the shared loader, and every symbol resolved by name. The plugin builds on a machine without the library; whether the library is there is a question for run time.

#include "DominRuntimeLib.h"      /* the shared loader: runtime/ first, then the system */
#include "DominPluginCommon.h"

/* [A] the library: candidate file names per platform, NULL-terminated */
static const char *const gLibCandidates[] =
{
#if defined(_WIN32)
    "ucrtbase.dll", "msvcrt.dll", NULL
#else
    "libm.so.6", "libm.so", NULL
#endif
};

static struct { void *Handle; double (*cbrt)(double); double (*hypot)(double, double); } gLib;

/* opens the library and resolves every symbol by name; a library without
   our symbols counts as "wrong version" and is reported like "not installed" */
static int EnsureLibLoaded(void)
{
    void *H;
    if (gLib.Handle != NULL) { return(1); }
    H = (void *)DominRtl_LoadAny(gLibCandidates, NULL);   /* 1 runtime/  2 dev tree  3 the system */
    if (H == NULL) { return(0); }
    gLib.cbrt  = (double (*)(double))         DominRtl_Sym(H, "cbrt");
    gLib.hypot = (double (*)(double, double)) DominRtl_Sym(H, "hypot");
    if ((gLib.cbrt == NULL) || (gLib.hypot == NULL)) { DominRtl_Close(H); return(0); }
    gLib.Handle = H;
    return(1);
}

[B] The verbs are 1:1 with the library. One verb per entry point, same name, same meaning; nothing clever. A verb does not check the handle — it cannot be running unless Ready succeeded.

/* [B] the thin 1:1 layer: one verb per library entry point, same name, same meaning.
   The parameter table (one number) is checked by the host before the call. */
static int Cbrt(DominRuntimeContext *Ctx, int ArgCount, const DominArgument *Args, DominValue *Result)
{
    (void)Ctx; (void)ArgCount;
    Result->Type = DOMIN_VALUE_FLOAT;
    Result->As.FloatValue = gLib.cbrt(Args[0].As.Value.As.FloatValue);   /* the handle is valid: Ready opened it */
    return(DOMIN_STATUS_OK);
}

[C] Ready opens the library; Deinit closes it. This is the difference from the plain plugin. If the library is missing, Ready answers NOT_READY with the message DominRtl_NotFound writes — the file to copy and the absolute path of the runtime folder — and the host refuses to start the script, quoting it. No verb ever runs against a missing library.

/* [C] the init / deinit hooks -- the part that makes it an adapter */
static int PluginReady(char *Message, size_t Cap)
{
    if (EnsureLibLoaded() != 0)
    {
        return(DOMIN_PLUGIN_READY);           /* nothing to say: Message stays empty */
    }
    /* "the math library not found. Install it, or copy libm.so.6 into /home/anna/dominscript/runtime" */
    DominRtl_NotFound(Message, Cap, "the math library", gLibCandidates[0]);
    return(DOMIN_PLUGIN_NOT_READY);           /* the script does not start */
}

static int PluginDeinit(char *Message, size_t Cap)
{
    (void)Message; (void)Cap;                 /* or: a note about what was released */
    if (gLib.Handle != NULL) { DominRtl_Close(gLib.Handle); gLib.Handle = NULL; }
    return(DOMIN_PLUGIN_DEINIT_OK);
}

DOMIN_PLUGIN_DEFINE_EXPORT_WITH_HOST_API("AdapterSkeletonPlugin", gFuncs, NULL, gHostApi)

That is the complete adapter pattern. plugins/adapter_skeleton in the tree is this file with every obligation numbered, plus the two things a real adapter often adds: a background thread with its own mutex and stop flag, and callbacks from that thread into the script. The official adapters — sdl2, sdl2image, sqlite, tls, mysql, postgres, json, ffmpeg — are the same three parts at larger scale.

The optional parts — and what each one buys

The host API (static DominHostApi gHostApi; plus the _WITH_HOST_API export). Without it a verb can only return a status. With it, it can report a message (DominPlugin_SetError — the script sees hello.Greet: the name is empty. at the call’s position, like any runtime error), emit a non-fatal notice (DominPlugin_Notice), and ask the host for a result buffer (DominPlugin_ResultString: inside a fused loop the string lands in the loop’s arena with no allocation; elsewhere the call returns NULL and the verb mallocs as usual). Every hook is size-checked, so a plugin built against an older header keeps working.

The traits (a DominFunctionTraits array parallel to the function table). They tell the loop machine what it may do with a verb inside a fused loop: a pure verb (no state, no I/O — str.Upper, math.Sqrt, both verbs here) becomes a machine op called straight from registers; a loop-safe stateful verb (a read like map.GetInt, or an idempotent write like map.PutInt) too, under a rule the compiler enforces. Without traits the verb is an ordinary call: correct, and slower in a loop. Wrong traits cost speed, never correctness — a verb returning a type other than declared drops the loop to the tree walker.

The shutdown hook (the NULL in the export line): a function the host calls once when the script ends, for plugins that own threads, sockets or files.

The doc comment. The block at the top of the file and the comment above each function are what the documentation generator turns into the plugin’s reference page (Tools/plugin_docs); the shipped plugins’ help pages on this site come from the same source.

How each part connects to the whole

Bind time. The script’s plugin "…" line loads the library and asks it for its export. From then on the binder resolves hello.Add(2, 40) against the function table: wrong count, a string where an integer is expected, a missing verb — all reported before anything runs, with the script’s own line and column. This is the same pass that resolves variables and types, so a plugin call is as static as a language operator.

Run time. The tree walker calls the C entry with the evaluated arguments. Ownership is simple and fixed: strings and blobs the script passes in are borrowed for the duration of the call (do not free, do not keep — copy if you need them later; the retain_skeleton shows how); a string or blob the verb returns is owned by the host from the moment the function returns (allocate with malloc, or take the host’s buffer, and never touch it again). A ref parameter is the script’s own variable, and writing it is the way a verb hands back more than one value.

Inside a fused loop. If the traits allow it, the loop machine compiles the call into one op: the arguments come from the machine’s registers (an integer, a double, a string built in the arena), the result goes back into a register, and the verb is called directly — no argument marshaling, no re-validation. A verb that fails there returns its status; the machine hands the statement to the tree walker, which calls the verb again and raises the message. That is why a loop-eligible verb must be repeatable: a second call with the same arguments must give the same answer and leave the same state. The 19-row benchmark table on the front page is what this buys.

Documentation and tests. The reference page is generated from the source comments and the parameter table; the site’s help pages are audited against the shipped plugins (every verb documented, no phantom verbs). A plugin’s tests are ordinary .dom scripts in the suite, run by the same runner as everything else; the two for hello are Tests/test_hello_plugin.dom and Tests/test_invalid_hello_empty_name.dom.

Build it, use it

# Linux
gcc -O2 -shared -fPIC -I Include -o HelloPlugin.so plugins/hello/HelloPlugin.c

# Windows (mingw-w64)
gcc -O2 -shared -I Include -o HelloPlugin.dll plugins/hello/HelloPlugin.c

The shipped plugins use a small Makefile (copy plugins/hello/Makefile); the one-liner above is the whole of what it does. Then, in a script:

plugin "builtin:print";
plugin "../plugins/hello/HelloPlugin";

i32 main()
{
    printf("%d %s\n", hello.Add(2, 40), hello.Greet("World"));   // 42 Hello, World!
    return(0);
}

The path after plugin is relative to the script and has no extension: the host appends .so or .dll for the platform. The same script runs unchanged on both.

The two skeletons: plain and adapter

hello is the smallest complete plugin; it is not the whole checklist. Two skeletons are: every obligation a plugin author has to meet is there once, numbered, next to the code that meets it. Copy the one that matches what you are building, keep the parts that apply, delete the rest.

plugins/plugin_skeleton — the plain plugin (namespace skel, 12 verbs). Twelve numbered points: the host API copy; the parameter table that lets the C function trust its arguments; value arguments are the host’s (read, never free, never keep); a ref parameter is a zero-copy, borrowed view; the error contract (an error only when the call cannot be completed correctly — otherwise a return value and a notice); string results through the host’s buffer; blob results as a copy the host then owns; plugin-side state; a verb that keeps a blob argument (the retains trait and ReleaseBlob); the traits table as a promise (a pure verb must not touch state, I/O or time); the variadic shape; and the shutdown hook that frees what the plugin still owns.

plugins/adapter_skeleton — the adapter (namespace adapt). Everything above, plus what only adapters need: [A] the library is loaded at run time, never linked — a candidate list of file names per platform, the shared loader (DominRuntimeLib.h: the runtime folder first, then the system), one function-pointer table; the plugin builds without the library, and its PluginReady opens it at load time, so a missing library stops the script before it starts, with the file name and the folder to copy it into; [B] a thin 1:1 layer (adapt.Cbrt is cbrt) that a convenience facade may sit beside, never replace; [C] the plugin’s own thread — a mutex around every shared field, a stop flag the thread polls, Wait/Stop joining it, and a shutdown hook that always joins (the library is unloaded right after); [D] callbacks from that thread: register on the script thread, hand the request bytes with a timeout (never infinite from a thread the host cannot see), treat a shutdown status as “stop, do not retry”, copy the host’s error text at once (it is a borrowed pointer), destroy the response blob, and remember that the callback runs in the callback world, in parallel with main; [E] nothing application-specific in an adapter — the script composes, the adapter exposes.

Both skeletons ship with a test that exercises every verb, and both run clean under valgrind and the thread sanitizer — that is the proof that the checklist is complete: a plugin written from the skeleton leaks nothing and races nowhere.

Next: the larger skeletons

plugins/example is the variadic form (any number of numeric arguments). plugins/callback_skeleton is for plugins that call the script on their own schedule — timers, listeners, async jobs — and plugins/retain_skeleton for plugins that keep data the script gave them; both come with a step-by-step guide (PluginCallbackSkeletonGuide.txt) and an example script. The full contract — every type, the callback dispatch rules, blob ownership, the thread-safety model — is PluginApiReference.txt, and the reasoning behind the patterns is PluginDesignPrinciples.txt. Start from hello to read, from plugin_skeleton or adapter_skeleton to write; reach for the others when your plugin needs a queue or a cache.