Kits and manifests
A Kit is a curated, version-pinned manifest of plugins and default option values. It is the unit of reuse for hosts.
What a kit is
A kit is not an executable. It is a list of plugin ids, their versions, any default option values, and an optional target. The host loads the kit, applies any overrides from .handl.toml, resolves dependencies, and runs contribute to a fixed point.
Example kit manifest
id = "canonical"
name = "Canonical handl kit"
plugins = [
"handl-plugin-strings",
"handl-plugin-numbers",
"handl-plugin-identifiers",
"handl-plugin-let",
"handl-plugin-function",
"handl-plugin-grammar-infix-pipe",
"handl-plugin-grammar-apply",
"handl-plugin-lint-unused-imports",
]
target = "native"
[options]
"comment.style" = "above"
"no-unused-imports.level" = "error"Why kits matter
Kits make hosts reproducible. A kit pins the exact plugins and defaults it contains. An executable host such as handl-host-cli only needs to declare which kits it loads. This separates the curation of language features from the mechanics of the host.
Kits also let users compose languages. A server kit might include HTTP and JSON plugins. A sandbox kit might exclude file-system plugins. A domain-specific kit might add custom grammar and formatter rules. The same Host can load any of these without recompilation.
The kit loading contract
When a host loads a kit:
- The kit manifest is parsed.
- Each plugin id is matched against
available_pluginssupplied by the kit crate. - The
HostConfigis built by merging all included kits, applying excludes, and adding direct plugins. Host::loadresolves dependencies and runscontributeto a fixed point.
If a kit requests a plugin that is not in available_plugins, loading fails with a clear error. This prevents a kit from silently depending on code the host has not loaded.
Excluding plugins
A .handl.toml can exclude a plugin from a kit without forking the kit:
[[kit]]
include = "handl-kit-prelude"
exclude = ["handl-plugin-deprecated-grammar"]Exclusion is useful for removing experimental or untrusted plugins. It is not a security boundary; a buggy plugin that remains loaded can still compromise the process.
Curating a kit
To create a new kit:
- Create
handl-kit-mynamecrate. - Write a
KitManifestthat lists the plugins and defaults. - Expose the manifest and
available_plugins. - Publish the crate or use it as a path dependency.
A kit should be a closed manifest. The host should not discover plugins on disk at runtime.
Loading a language from a data directory (per-file kits)
A kit can also be assembled from authored YAML files rather than from plugin ids — the per-file path of ADR 0003:
use handl_kit_data_canonical::language_from_dir;
let lang = language_from_dir(std::path::Path::new("my-language/"))?;language_from_dir reads ten files from the directory and merges them the same way language_from_yaml merges the embedded canonical files:
| file | required |
|---|---|
tokens.yaml, grammar.yaml, eval.yaml, types.yaml, ast.yaml, caps.yaml, options.yaml, lexer_rules.yaml | yes (empty [] is valid) |
transforms.yaml, lints.yaml, lsp.yaml | optional (absent = none) |
Two things bite when writing these files by hand or from another tool:
- Enum variants are YAML tags, not maps.
serde_yamlspells a variant as!Declarative/!Create/!KeepPath/!KindAt/!And, whereserde_jsonspells it{"Declarative": …}. A map in a tagged position fails with "invalid type: map, expected a YAML tag starting with '!'". - Each file is one sequence. A fragment file is a YAML list of rows (
- id: …), not a bare mapping.
Worked external example: catopt
The catopt project (categorical optimization of computation graphs) emits its vocabulary in exactly this layout and loads it back:
- generator:
catopt'slab/handl-spike/dump_fragments.py; - fixture:
crates/handl-kit-data-canonical/tests/data/catopt-fragments/(eight empty shell files plusast.yamlandtransforms.yaml); - test:
crates/handl-kit-data-canonical/tests/catopt_fragments.rs.
That is the spike that established the boundary: a foreign project's laws land in lang.transforms with their fields intact — all 83 of them (catopt_full_bank_loads) — and a foreign law executes through the rewrite engine (catopt_law_fires_through_the_rewrite_engine), producing exactly the term the producer's own engine produces. Rows may carry extra keys — serde ignores unknown fields — so a producer can ride its own authoritative record alongside the handl-shaped view (catopt uses a catopt: key for this).
Not yet reachable from the CLI. hnl selects kits through registered emitters; a data directory is reachable through this API (or a host that calls it). FragmentSource::DataFile is modeled in handl-kit-data but returns LangError::NotImplemented — the runtime half of the seam is still to come.