Docs

Plugins

Declaring what a plugin exports, what adm build packs into an .admplugin, and how it shares the runtime with its host.

Declaring a plugin

A plugin is a compilation unit like an application, without an entry point: plugin Name { } at file scope, partial allowed, its own use list. What makes it a plugin is the export list: the modules and symbols a host may reach, written with the same selectors as use. Everything else in the plugin stays private.

imagetools.adm
@manifest(version = "1.2.0")
plugin ImageTools {
	use (
		std.io
		acme.imaging
		acme.imaging.filters
	)

	export (
		acme.imaging.filters              // every non-internal symbol of the module
		acme.imaging::(Image, decode)     // a subset
		ImageTools::(about)               // a function of the plugin block
	)

	def about() string { ... }

	def new() !none { ... }              // optional lifecycle
	def dispose() { ... }
}

The compiler checks the list: every module named is one the plugin uses, every symbol exists and is not internal, and the signatures of exported symbols only mention types that are exported too, so a host never sees a type it cannot name. @manifest(version = ...) sets the plugin's version; without it the version is 0.1.

A plugin's module names stay inside it. What it publishes is one flat set of names (Image, about, blur, decode), so the same name exported from two modules is a compile error. A function declared in the plugin block is exported only when the list names it, with the plugin's own name as the selector. A plugin exports no service: naming one is an error, and a whole-module export leaves the module's services out.

Building

adm build on a plugin unit produces one file, <Name>.admplugin, a zip archive that holds everything a host or an IDE needs:

ImageTools.admplugin
manifest.json                  name, version, exports, entry symbol,
                               compiler and runtime it was built with
meta/checksums.json            the SHA-256 of every other part
meta/signed.json               signed packages: the exact bytes the publisher signed
meta/exports.json              every published name: kind, canonical signature,
                               a type's fields and methods, doc
meta/layouts.json              how each type a host can meet is laid out
linux-amd64/libImageTools.so   the native object, uncompressed, mappable
linux-arm64/libImageTools.so   one directory per platform requested
windows-amd64/ImageTools.dll   (a DLL on Windows)
files/...                      what the [package] files rules of adm.toml select
                               (assets, headers), as for a library

The shared object exports a single symbol, the plugin's entry, and is linked position-independent with its own symbols bound, so two plugins never clash with each other or with the host. adm build --platforms linux-amd64,linux-arm64,darwin-arm64,windows-amd64 runs the build once per platform and merges the results into one package; a cross target needs the platform's sysroot, named by ADM_SYSROOT_<OS>_<ARCH>, and without one only the host platform is built. Applications built the same way land in <out>/<os>-<arch>/.

Inspecting a package

adm doc --plugin ImageTools.admplugin prints the manifest and the export index without loading any code: the published names with their signatures and doc comments, the fields and methods of each exported type, the platforms inside, the permissions its code reaches, and the compiler and runtime versions it was built against. Tooling reads the same entries, so what the IDE shows for a plugin is exactly what it exports. A signature is written in one canonical spelling, without parameter names or defaults (def(byte[]) !Image): a std type keeps its module (std.io.Writer), every other type is its bare name. A generic function or type is not published: it has no code until something instantiates it, and naming one in an export selector is a compile error.

The shared runtime

Native code needs the ADM runtime: memory management, tasks, strings, the service registry. An application normally carries it inside its binary. A plugin cannot, because two copies of the runtime in one process would each count references and each run a scheduler, so plugins are always linked against libadm_runtime.so (.dylib on macOS, adm_runtime.dll on Windows), placed beside the output and found through a relative rpath (on Windows in the executable's folder), and the package records which runtime version it needs.

A host that loads plugins is built with adm build --runtime shared, which gives it the same library beside its binary; the plugin then shares the host's objects, tasks and services. Static linking stays the default for everything else because a shared runtime turns every retain and release into a call through the dynamic linker, a cost only a plugin host should pay. Linux, macOS and Windows.

Loading

A host reads plugin packages through the Runtime service and asks one for a value of an interface it declares itself. Host and plugin share no library: the published names and canonical signatures above are what the two sides are matched by.

host.adm
interface Effect {
	def process(samples float[], rate int)
}

for let (_, found) in Runtime.loadPlugins("./plugins") {
	effects.push(try found.create<Effect>()) when found.creates<Effect>()
}

Runtime.loadPlugins(folder) reads every .admplugin of a folder and Runtime.loadPlugin(path) one file; both read the metadata only. The Plugin they return holds the name, version, publisher, export list and the permissions the plugin's code reaches (recorded in the package at build), and four calls: creates<T>() answers from the metadata whether the plugin provides T; create<T>() returns a new object of the first exported type that has every method of T, or one object whose methods are the plugin's exported functions; createAll<T>() returns one per match; init() loads the code and runs the plugin's new() without creating anything. The plugin is loaded on first use, once, and stays for the life of the program.

A program that loads plugins is built with adm build --runtime shared, and a plugin must be built with the same adm as its host; both are checked when the plugin is loaded, and the error says which. On Linux the plugin's code is loaded straight from the package, without writing a file. Services are shared: a plugin that calls Storage or Network reaches the host's instance, and a service only the plugin has is started when the plugin is loaded. When a plugin's new() fails, the host's error carries its message.

Trust

Nothing of a package is used before it verifies. A package built with a publisher key is signed: the publisher's signature covers its name, version, exports, permissions and the checksum of every part, and a host checks it, then each part against its checksum before using it, the code last. On Linux the code that runs is the bytes that were checked, loaded from memory. A package that fails is still listed, with its name, and every call on it fails with the reason.

Which plugins a program loads is its own decision, written in its adm.toml:

adm.toml
[plugins]
allow_unsigned = true                # default false: an unsigned package does not load
trusted = ["self", "admpub_..."]     # default: any publisher whose signature verifies
permissions = ["adm.storage.read"]   # the most any plugin may be given

self is the application's own publisher, for a product that loads only its own plugins. Plugin.signed says the signature verified and Plugin.publisher who signed.

Permissions

To the policy a plugin is a package of its own, named after the plugin: whatever its code does, called through an export, through an interface or through a registry, needs the plugin to hold the permission. Plugin.permissions lists what its code reaches before anything is loaded. When it is loaded, each of those permissions that the host's [plugins] permissions list covers is put to the user once (through the program's resolver, else a desktop notification); an Always answer is kept in policy.toml under the plugin's name, for the publisher that signed it. A permission outside the list is refused without a question: the plugin still loads, and its calls under it fail with PolicyDenied.

The list is the host's own choice and not a part of what the host's code uses: a host that never touches the clipboard may let its plugins use it. A program needs the permission adm.runtime.plugins to load a plugin at all; an application holds it by calling create or init, a library is granted it at install.

Types that cross

A signature names a type that is not the standard library's by its bare name, so a host's Image and a plugin's Image match by name. They are one type only when they are laid out alike: the package records the layout of every type a host can meet (struct{width int; height int}, an interface's methods in order), the host compares it with its own, and a plugin whose type differs is refused with both layouts in the error. Standard library types are the same on both sides, since both are built with one adm.

A struct is a shape too. found.create<PluginDetails>() returns a copy of a value the plugin exports whose type has the fields of the host's struct, in the same order; the names of the two types need not match:

plugin and host
// in the plugin
struct Details {
	title string
	level int
}

let details = Details{title: "Echo", level: 3}

// in the host
struct PluginDetails {
	title string
	level int
}

let about = try found.create<PluginDetails>()

Plugins that register

A plugin that adds a codec or a database driver exports nothing a host asks for: its annotated types (a playlist format's @codec("LST"), a database driver's @driver("mydb")) join the program's when the host calls init(), and the standard library's registries find them on their next use, as reflection.attrs does. A registered codec runs as the plugin, with the plugin's permissions.

host.adm
for let (_, found) in Runtime.loadPlugins("./formats") {
	try found.init()
}
let entries = try playlist.load("songs.lst")   // read by the plugin's codec