# Zig Plugins & Stdlib Namespaces
All Deft stdlib functions — `str`, `echo`, `http/get`, `tui/split`, `sqlite/open` —
are written in Zig and discovered at build time, with no central manifest to
edit. This document covers the plugin architecture, how pseudo-namespaces work,
and how to add a new plugin.
## Architecture
```
┌──────────────────────────────────────────────────────────────┐
│ Build time │
│ │
│ build.zig ─► gen_plugin_registry.zig │
│ │
│ walks src/host/plugins/** + src/host/stdext/* │
│ looks for `pub const info` in every .zig file │
│ writes plugin_registry.gen.zig (only if changed) │
│ │
│ Each plugin file exports one public constant: │
│ pub const info: PluginInfo = ...; │
│ │
│ Optional feature gates: │
│ pub const requires_tui: bool = true; (TUI-only) │
│ pub const requires_ffi: bool = true; (FFI-only) │
└──────────────────────┬───────────────────────────────────────┘
│ @import
┌──────────────────────▼───────────────────────────────────────┐
│ VM init │
│ │
│ stdlib.registerAll(alloc, heap, &vm.globals); │
│ stdlib.registerAll(alloc, heap, &vm.core_ns.vars); │
│ │
│ Each PluginEntry becomes an ObjNativeFunc stored as a │
│ flat string key in globals and core_ns: │
│ globals["fs/read"] = │
│ globals["fs/write"] = │
│ globals["string/upper"] = │
└──────────────────────────────────────────────────────────────┘
```
## The PluginInfo Contract
Every plugin file exports a single `PluginInfo` struct:
```zig
pub const info: PluginInfo = ...;
```
Its fields:
| Field | Type | Purpose |
|---|---|---|
| `name` | `[]const u8` | Plugin identifier, e.g. `"string"`, `"fs"`, `"sqlite"` |
| `version` | `u32` | API version (default `1`) |
| `entries` | `[]const PluginEntry` | The function table |
Each `PluginEntry` describes one stdlib function:
| Field | Type | Purpose |
|---|---|---|
| `name` | `[]const u8` | Fully-qualified name, e.g. `"fs/read"`, `"string/trim"` |
| `arity` | `i16` | Fixed argument count, or `-1` for variadic |
| `func` | `NativeFn` | The function pointer |
| `flags` | `FnFlags` | `.needs_vm = true` for HOFs, coroutines, I/O |
| `doc` | `?[]const u8` | Help string |
| `params` | `?[]const ParamDoc` | Per-parameter docs |
| `returns` | `?[]const u8` | Return value description |
| `flag_opts_npos` | `u16` | CLI-style `--flag value` support (0 = off) |
### Function signatures
Two signatures are supported, determined by the entry's `flags.needs_vm`:
```zig
// Pure functions — FnFlags.needs_vm is false
pub const NativeFn = *const fn (*DeftCtx, []const Value) Value;
// VM-aware — FnFlags.needs_vm is true (HOFs, coroutines, I/O)
pub const VmNativeFn = *const fn (*anyopaque, []const Value) Value;
```
The `DeftCtx` passed to every stdlib function provides:
- **Allocation**: `.alloc`, `.heap`
- **VM/runtime access**: `.vm`, `.runtime` (typed cast via `.vmAs(T)`, `.runtimeAs(T)`)
- **Arg extraction**: `.str(i, args)`, `.num(i, args)`, `.int(i, args)`, `.boolean(i, args)`, `.kw(i, args)`
- **Value construction**: `.newString(s)`, `.newNumber(n)`, `.newBool(b)`, `.newKeyword(k)`
- **Result construction**: `.ok(v)` → `%Ok`, `.err(msg)` / `.errVal(v)` → `%Err`
## Comptime Convenience: `plugin.decl()` and `plugin.derive()`
Instead of hand-writing `PluginEntry` structs and manual `[]const Value`
unpacking, most plugins use two comptime macros that auto-derive arity,
type-check arg extraction, and build the registration table:
```zig
const plugin = @import("../../runtime/plugin.zig");
const DeftCtx = plugin.DeftCtx;
const Value = plugin.Value;
const d = plugin.decl;
pub const info = plugin.derive("math", .{
d("sqrt", &nativeSqrt, .{ .doc = "Square root", .returns = "number" }),
d("sin", &nativeSin, .{ .doc = "Sine of radians", .returns = "number" }),
d("pow", &nativePow, .{
.doc = "Raise base to exponent",
.params = &.{ .{ .name = "base" }, .{ .name = "exp" } },
.returns = "number",
}),
});
```
`decl` recognizes two author signatures:
- **Typed** — `fn(*DeftCtx, s: []const u8, n: i64) Value` → fixed arity derived from param count
- **Raw** — `fn(*DeftCtx, []const Value) Value` → variadic (arity `-1`)
Supported typed arg types: `[]const u8`, `f64`, `i64`, `bool`, `Value`.
Return type may be `Value` or `!Value` (errors auto-convert to `%Err`).
Max 4 typed params; for more, use the raw `[]const Value` form.
`derive(name, entries)` builds a `PluginInfo` from a tuple of `PluginEntry`
values, setting `.name` and embedding the entries array at comptime.
### Without the macros (raw form)
For reference, the same plugin written manually:
```zig
fn nativeSqrt(ctx: *DeftCtx, args: []const Value) Value {
const x = ctx.num(0, args) orelse return ctx.err("expected number");
if (x < 0) return ctx.err("sqrt: negative argument");
return ctx.newNumber(std.math.sqrt(x));
}
pub const info = plugin.PluginInfo{
.name = "math",
.version = 1,
.entries = &.{
.{ .name = "sqrt", .arity = 1, .func = &nativeSqrt,
.doc = "Square root", .returns = "number" },
.{ .name = "sin", .arity = 1, .func = &nativeSin,
.doc = "Sine of radians", .returns = "number" },
},
};
```
The macros are preferred: less boilerplate, fewer manual `[]const Value`
indices, and automatic arity derivation.
## How Pseudo-Namespaces Work
Unlike Clojure-style namespaces (created by `defmod` in `.dft` files), plugin
namespaces are **flat-key pseudo-namespaces**. There is no `Namespace` struct
for `fs`, `tui`, or `sqlite`. The `/` in `"fs/read"` is purely a naming
convention — each entry is stored as a flat string key:
```
globals["fs/read"] = // from fs plugin
globals["fs/write"] = // from fs plugin
globals["fs/home"] = // from fs plugin
globals["tui/split"] = // from workspace plugin
globals["tui/close"] = // from workspace plugin
```
When Deft code references `fs` as a bare name (e.g. to inspect it or pass it
around), the VM **lazily synthesizes** a namespace map via `synthesizePluginMap`:
1. Scans all globals matching the prefix `"fs/"`
2. Strips the prefix to get short names: `read`, `write`, `home`
3. Builds a persistent map `{ :read , :write , :home }`
4. Stores it as `globals["fs"]` for future fast access
This happens transparently — you never call `synthesizePluginMap` yourself.
The VM does it on first bare-name access.
### Resolution path for a qualified call
When code calls `[fs/read "file.txt"]`:
1. The bytecode compiler emits a `load "fs/read"` instruction
2. `resolveVar("fs/read")` checks `globals` → finds the `ObjNativeFunc`
3. `dispatchCall` invokes the native function pointer directly
## Feature Gating
Plugins can be gated behind build-time feature flags:
```zig
pub const requires_tui: bool = true; // Only included in retro/retra builds
pub const requires_ffi: bool = true; // Only included in FFI-enabled builds
```
If neither is set, the plugin is **always loaded** (common). The registry
generator partitions plugins into three arrays — `common`, `tui_only`,
`ffi_only` — and concatenates them at init time.
Check feature availability at runtime:
[capabilities] # → map of all feature flags [get [capabilities] :tui] # → true/false [get [capabilities] :ffi] # → true/false
## Build-Time Discovery
Adding a new plugin requires **zero manual edits to any manifest**. Drop a file
under `src/host/plugins/` or `src/host/stdext/` that exports `pub const info`,
and the build system discovers it automatically:
1. `build.zig` calls `gen_plugin_registry.zig` during graph construction
2. The generator walks both directories recursively
3. For each `.zig` file containing `pub const info`, it reads the feature gates and emits an `@import` reference
4. The output `plugin_registry.gen.zig` is written only if content changed (avoids spurious recompilation)
5. The registry uses conditional compilation (`if (build_options.enable_tui)`) so TUI/FFI modules are never analyzed in builds that don't need them
## Adding a New Plugin
### Example: a `compress/` plugin
Create `src/host/plugins/core/compress_plugin.zig`:
```zig
const std = @import("std");
const plugin = @import("../../runtime/plugin.zig");
const DeftCtx = plugin.DeftCtx;
const Value = plugin.Value;
const d = plugin.decl;
const value_mod = @import("../../../kernel/value/value.zig");
pub const info = plugin.derive("compress", .{
d("compress/gzip", &nativeGzip, .{
.doc = "Compress bytes with gzip",
.params = &.{.{ .name = "data", .type_hint = "string" }},
.returns = "string",
}),
d("compress/decompress-gzip", &nativeGunzip, .{
.doc = "Decompress a gzip-compressed string",
.params = &.{.{ .name = "data", .type_hint = "string" }},
.returns = "string",
}),
});
fn nativeGzip(ctx: *DeftCtx, args: []const Value) Value {
const data = ctx.str(0, args) orelse return ctx.err("expected string");
// Compress with std.compress.gzip...
return ctx.ok(ctx.newString(compressed_bytes));
}
fn nativeGunzip(ctx: *DeftCtx, args: []const Value) Value {
const data = ctx.str(0, args) orelse return ctx.err("expected string");
// Decompress with std.compress.gzip...
return ctx.ok(ctx.newString(decompressed_bytes));
}
```
Rebuild. The functions `compress/gzip` and `compress/decompress-gzip` are now
available in all Deft code. The `compress` bare name lazily synthesizes a map
`{ :gzip , :decompress-gzip }`.
### TUI-only plugin example
Add `pub const requires_tui = true`:
```zig
pub const requires_tui: bool = true;
```
The plugin is only compiled into `retro`/`retra` builds. The scripting binary
`deft` never sees its imports.
## Plugin Locations
| Directory | Purpose |
|---|---|
| `src/host/plugins/core/` | Core language stdlib functions (string, math, types, list, map, set, etc.) |
| `src/host/plugins/data/` | Data structure stdlib functions (array, list, map, set) |
| `src/host/plugins/system/` | System stdlib functions (config, io, log, pubsub, rpc, runtime, task) |
| `src/host/plugins/` (root) | TUI, dialog, debug, capabilities, workspace, draw, editor, etc. |
| `src/host/stdext/` | Standard library (fs, http, json, sqlite, os, net, crypto, etc.) |
The split is convention, not enforcement. Any file under either tree is
discovered automatically.
## Error Convention
Stdlib functions return errors as `%Err` tagged maps. Use the `DeftCtx` helpers:
```zig
fn nativeFn(ctx: *DeftCtx, args: []const Value) Value {
const s = ctx.str(0, args) orelse return ctx.err("expected string");
// ... do work ...
return ctx.ok(result); // → %{ :type :ok/value :value result }
}
```
For raw-arity functions returning `!Value` via `decl`, errors auto-convert:
```zig
fn nativeOpen(ctx: *DeftCtx, path: []const u8) !Value {
const f = try std.fs.cwd().openFile(path, .{});
defer f.close();
// ...
return ctx.newString(content);
}
// On error → ctx.err(@errorName(e)) automatically
```
## Namespace Naming Conventions
- **Short name** with no `/` for frequently-used standalone functions:
`str`, `trim`, `echo`, `map`, `filter`, `nil?`
- **Namespace-slash** for logically grouped operations:
`fs/read`, `fs/write`, `http/get`, `sqlite/query`, `tui/split`
- The slash is just a naming convention. No real namespace struct exists for
the plugin prefix — the VM lazily synthesizes one on first bare-name access.
## Limits
- **Max 4 typed params** via `plugin.decl()`. Use the raw `[]const Value`
form for variadic or higher-arity functions.
- **Name collisions** between plugins are not detected at build time.
If two plugins register `"foo/bar"`, the second registration silently
overwrites the first.
- **Dynamic plugin loading** (`.so`-based) is planned but not yet shipped.
All plugins are currently statically linked.