Rust kit
Setting up and building a Rust plugin, and every function and type in the crate.
The Rust kit is the hammer-plugin-kit crate (version 0.8.0), whose library is named hammer. Its source is rust/hammer in hammer-plugin-sdk. It wraps Hammer's imports, reads each call's input, writes the reply, and provides serde types for the export, action, and diagnose exports. It also reads image references, and reaches services on the writer's sync server. It targets version 1 of the host API.
Here is a complete plugin. It adds an action that lists the project's encyclopedia entries.
manifest.toml:
id = "entry-count"
name = "Entry count"
version = "0.1.0"
api = 1
description = "Says how many entries the encyclopedia has."
[permissions]
operations = ["entry.list"]
[[actions]]
name = "count"
label = "Count entries"
src/lib.rs:
use hammer::action::{Reply, Request};
use hammer::ops;
#[unsafe(no_mangle)]
pub extern "C" fn action() -> i32 {
hammer::run(|| {
let request: Request = hammer::input_json()?;
let listed = ops::entry::list(&request.project, Default::default())?;
let names: Vec<&str> = listed.entries.iter().map(|entry| entry.name.as_str()).collect();
Reply::message(format!("{} entries: {}", names.len(), names.join(", "))).to_json()
})
}
The manifest is described in /reference/manifest, actions in /reference/actions, and entry.list in /reference/operations.
Setup
Dependency
The kit is on crates.io as hammer-plugin-kit, because the name hammer is taken. Its library is named hammer, so your code writes use hammer::…:
[dependencies]
hammer-plugin-kit = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
The kit doesn't re-export serde or serde_json. If your plugin derives its own types or builds JSON, add them as dependencies yourself.
To build against a checkout of hammer-plugin-sdk instead, depend on it by path:
hammer-plugin-kit = { path = "../hammer-plugin-sdk/rust/hammer" }
The crate uses edition 2024, so you need Rust 1.85 or later.
Crate type and target
A plugin is a cdylib built for the wasm32-unknown-unknown target. Set the crate type and install the target:
[lib]
crate-type = ["cdylib"]
rustup target add wasm32-unknown-unknown
Then pass --target wasm32-unknown-unknown to cargo, or set the target once in .cargo/config.toml:
[build]
target = "wasm32-unknown-unknown"
Release profile
Hammer's own Rust plugins build with this profile, which keeps the module small:
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
panic = "abort"
strip = true
Put the profile in the Cargo.toml at your workspace root. Even with panic = "abort", the writer still sees a panic's message; see hammer::run.
The getrandom feature
Some crates get random numbers through getrandom 0.3, which has no backend for wasm32-unknown-unknown. The kit's getrandom feature supplies one using WASI's random_get, which is the only WASI function Hammer offers for random numbers:
hammer-plugin-kit = { version = "0.8", features = ["getrandom"] }
getrandom only uses that backend when it's built with --cfg getrandom_backend="custom". Set this in your plugin workspace's .cargo/config.toml:
[target.wasm32-unknown-unknown]
rustflags = ['--cfg', 'getrandom_backend="custom"']
The feature adds no public items. It defines getrandom's __getrandom_v03_custom hook. If random_get fails, the hook returns getrandom::Error::UNSUPPORTED.
Building and packing
Build
cargo build --release --target wasm32-unknown-unknown
Cargo writes the module to target/wasm32-unknown-unknown/release/<crate>.wasm, with any hyphens in the crate name turned into underscores (entry-count becomes entry_count.wasm).
tools/package.sh
../hammer-plugin-sdk/tools/package.sh <plugin directory> <module.wasm>
This script packs your module, renamed to plugin.wasm, together with whichever of these the directory contains: manifest.toml, settings.toml, locales/, and resources/. It writes the package to <plugin directory>/build/<directory name>.hammerplugin. The script needs zip. To install the package, go to Settings, Plugins in Hammer.
Each hammer-plugin-sdk release on GitHub carries tools/package.sh and rust/tools/finish.py, so you don't need a checkout to use them.
rust/tools/finish.py
python3 ../hammer-plugin-sdk/rust/tools/finish.py <in.wasm> <out.wasm>
You only need this script for crates written for the browser, such as those that use wasm-bindgen. These crates leave behind exports and imports that only a JavaScript host provides. The script:
- keeps your plugin's own exports (those that don't start with
__) - drops any code those exports can't reach
- replaces each wasm-bindgen import that the remaining code still calls with a trap
- fails if the module still imports anything Hammer doesn't offer
Hammer offers extism:host/env, extism:host/user, and WASI's random_get and clock_time_get.
The script needs wabt (wasm-objdump, wat2wasm) and Binaryen (wasm-metadce, wasm-merge). Put them on your PATH, or set BINARYEN to Binaryen's bin directory. If one of them fails, the script stops and prints the command, its exit status, and its own error output. The script doesn't run wasm-opt, because the release profile has already optimized the code.
Wizer and ZeroingAllocator
If your plugin does slow work at start-up, you can do that work at build time with Wizer. Wizer runs an initializer export and saves the memory it leaves behind into the module, so Hammer loads your plugin already initialized. harper-grammar builds its linter this way:
#[global_allocator]
static ALLOCATOR: hammer::ZeroingAllocator = hammer::ZeroingAllocator;
#[unsafe(export_name = "wizer.initialize")]
pub extern "C" fn initialize() {
// Build and keep whatever every call needs.
}
Wizer saves only the non-zero bytes of memory. ZeroingAllocator zeroes any memory that start-up freed, so the module carries only the data start-up kept. harper-grammar's build.sh runs the steps in this order:
cargo build --release -p harper-grammar
python3 "$sdk/rust/tools/finish.py" ../target/wasm32-unknown-unknown/release/harper_grammar.wasm build/finished.wasm
wizer --allow-wasi --wasm-bulk-memory true --wasm-reference-types true -o build/harper-grammar.wasm build/finished.wasm
"$sdk/tools/package.sh" . build/harper-grammar.wasm
Install Wizer with cargo install wizer --all-features.
A cargo-hammer subcommand is planned that will build, finish, and pack your plugin in one step (cargo install cargo-hammer, then cargo hammer package).
Declaring exports
Hammer calls your plugin's exports by name: export, action, command, and diagnose (see the pages on export formats, actions, text checks and commands). A plugin has at most one of each, so a plugin with two actions tells them apart with Request::action. Write each export as a #[unsafe(no_mangle)] pub extern "C" fn that takes no arguments and returns i32, with hammer::run as its body:
#[unsafe(no_mangle)]
pub extern "C" fn diagnose() -> i32 {
hammer::run(|| {
let request: hammer::diagnose::Request = hammer::input_json()?;
let reply = hammer::diagnose::Reply::default();
Ok(serde_json::to_vec(&reply)?)
})
}
The kit has types for export, action, and diagnose. For command, define a serde struct for the input described in Commands and read it with input_json. Every export's input includes locale, the UI's language tag.
Crate root
hammer::run
pub fn run<O: AsRef<[u8]>>(body: impl FnOnce() -> Result<O>) -> i32
Runs the body of an export. If the body returns Ok, run sets the call's output to the returned bytes (a String or a Vec<u8> both work) and returns 0. If it returns Err, run sets the error's message as the call's error and returns 1. That message is what the writer sees.
The first time it's called, run installs a panic hook, replacing any existing one. If your plugin panics, the hook sets the panic's message as the call's error before the call traps.
hammer::input
pub fn input() -> Vec<u8>
Returns the call's input as raw bytes.
hammer::input_json
pub fn input_json<T: DeserializeOwned>() -> Result<T>
Parses the call's input as JSON into a T. If parsing fails, it returns an Err.
hammer::Error
pub type Error = Box<dyn std::error::Error>;
An error to report to the writer. ? converts any error into one, and .into() turns a &str or String message into one:
let request = hammer::input_json::<Request>().map_err(|_| "No project to report on")?;
hammer::Result
pub type Result<T> = std::result::Result<T, Error>;
The type that an export's body and most of the kit's functions return.
hammer::call
pub fn call<T: DeserializeOwned>(operation: &str, input: &impl Serialize) -> Result<T>
Runs a Hammer operation by name, such as call::<Tree>("scene.tree", &json!({"project": name})), and parses its output into a T. Prefer the typed functions in hammer::ops; use call when you want your own type for an output. The operation must be listed in your manifest's [permissions] operations.
If the operation fails, the error message starts with the kind of failure (NotFound, InvalidInput, PermissionDenied, or Failed), followed by : and the message. An output of null still counts as success, so use () or an Option as T for operations that return nothing. /reference/operations lists every operation with its input and output.
hammer::cache_get
pub fn cache_get(key: &str) -> Option<Vec<u8>>
Returns the value cached under key, or None if there isn't one. A key longer than CACHE_MAX_KEY bytes always returns None.
Each plugin has its own cache, which Hammer keeps between calls and between runs. Hammer may drop any value at any time, and it clears the whole cache when the plugin is installed again or uninstalled. A call that's still running on the old version during an install may write to the cache after it's cleared. If the format of a value might change between versions, put a version number in its key.
hammer::cache_set
pub fn cache_set(key: &str, value: &[u8]) -> bool
Caches value under key. Setting an empty value removes the entry. If the key is longer than CACHE_MAX_KEY bytes or the value is larger than CACHE_MAX_VALUE, it stores nothing and returns false.
hammer::CACHE_MAX_KEY
pub const CACHE_MAX_KEY: usize = 1024;
The maximum length of a cache key, in bytes.
hammer::CACHE_MAX_VALUE
pub const CACHE_MAX_VALUE: usize = 1024 * 1024;
The maximum size of a cache value, in bytes (1 MiB).
hammer::resource
pub fn resource(name: &str) -> Option<Vec<u8>>
Reads a file from the package's resources/ directory, by its path inside that directory, such as words/en.txt. Returns None if the package has no file with that name, or if the file is empty.
Hammer reads the file from the package on every call, so if you use a file often, keep a copy of it (for example, in a static).
hammer::progress
pub fn progress(fraction: Option<f64>, message: Option<&str>)
Reports how far an action has got. Pass fraction as a number from 0 to 1 when you know it (values outside that range are clamped), and message as a line saying what the action is doing. Hammer shows both while the action runs.
If the writer has stopped the run, this call doesn't return, and the action ends there.
hammer::char_byte_offsets
pub fn char_byte_offsets(text: &str) -> Vec<usize>
Returns the UTF-8 byte offset of each character in text, plus one more offset for the end of the text. Use it to turn a range counted in characters into the byte range Hammer expects: offsets[start]..offsets[end] is the byte range of characters start..end.
hammer::words
pub fn words(text: &str) -> impl Iterator<Item = Word<'_>>
Returns the words in text, in order, each as a Word with its byte range. A word is a run of letters, together with any combining marks on them, so accents, Indic vowel signs, and Arabic vowel marks stay in their word. These characters stay inside a word when they sit between two letters: an apostrophe (' or ’), the Catalan middle dot, Hebrew's geresh and gershayim, ZWNJ, ZWJ, and the soft hyphen. So "don't", "l'arbre", and "col·lecció" are one word each. Digits, hyphens, and everything else end a word.
Chinese and Japanese put no spaces between words, so each Han, kana, or Bopomofo character is a word of its own. Thai, Lao, Khmer, and Burmese don't use spaces either, but there each run of letters is one word.
Letters come from a table built from Unicode 17. The Kotlin kit's words and the C kit's hammer_words use the same table, so all three kits split text the same way.
for word in hammer::words(text).filter(|it| it.text.eq_ignore_ascii_case("very")) {
// word.start..word.end is the range a Diagnostic takes
}
hammer::Word
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Word<'a> {
pub text: &'a str,
pub start: usize,
pub end: usize,
}
A word found by words. text runs from byte start up to byte end of the text it came from. These are the UTF-8 byte offsets a Diagnostic takes.
hammer::API_VERSION
pub const API_VERSION: u32 = 1;
The host API version this kit targets. Your manifest's api must match it.
hammer::ZeroingAllocator
pub struct ZeroingAllocator;
unsafe impl GlobalAlloc for ZeroingAllocator
An allocator that zeroes memory when it's freed, and otherwise uses the system allocator. When Wizer pre-initializes a module, it saves only the non-zero bytes of its memory. With this allocator, the module carries the data start-up kept, not the data it threw away. Plugins that aren't built with Wizer don't need it.
#[global_allocator]
static ALLOCATOR: hammer::ZeroingAllocator = hammer::ZeroingAllocator;
hammer::ops
Every operation, as a typed function. Modules follow the operations' names, so scene.read is ops::scene::read and scene.meta.write is ops::scene::meta::write. Each function takes the operation's required inputs as parameters and returns its output as a struct from hammer::ops. An operation with optional inputs takes them last, in an …Options struct that has Default, so you set only the ones you need:
use hammer::ops::{self, EntryKind};
let scene = ops::scene::read(&project, 12)?;
let options = ops::entry::EntryListOptions { r#type: Some(EntryKind::Person), ..Default::default() };
let people = ops::entry::list(&project, options)?.entries;
Text inputs are &str and lists are slices, so you don't need to clone what you pass. Binary content, such as an image's file, is &[u8] as an input and Vec<u8> in an output; the kit converts it to and from the base64 text Hammer sends (see hammer::base64). Each function's documentation says which permission your manifest needs for it, and an error's message starts with its kind, as with hammer::call. A field or function named after a Rust keyword takes the r# prefix: r#type, ops::scene::r#move.
The modules are account, backup, draft, entry (with entry::images for an entry's images), export, idea, image (the project's images), note, ops, plugin, project, scene (with scene::meta, scene::read, and scene::references), stats, sync, tag, and timeline (with timeline::def and timeline::settings). Operations that Hammer withholds from plugins have no function.
The functions and types are generated from Hammer's own list of operations, so they always match the operations reference. Two details make them forgiving:
- Every output struct derives
Defaultand fills in any field Hammer leaves out. Hammer always sends every field, so this mostly matters when you build a value yourself, as inSceneText { markdown: "Rain fell.".into(), ..Default::default() }. - Every enum has an
Unknownvariant. A value your kit doesn't know, such as an entry kind added in a later Hammer, reads asUnknowninstead of failing. Hammer rejectsUnknownas an input.
hammer::log
Each function writes message to Hammer's log at its own level, using Extism's log imports. Lines appear in Hammer's own log as Plugin '<id>': <message>. Trace and debug messages are both logged at debug level.
hammer::log::debug
pub fn debug(message: &str)
hammer::log::info
pub fn info(message: &str)
hammer::log::warn
pub fn warn(message: &str)
hammer::log::error
pub fn error(message: &str)
hammer::images
Hammer writes a project image into markdown as , or with the destination in angle brackets, <hammer://image/<id>>. The alt text means nothing, because Hammer shows the image's own name. These functions read and write those references.
let scene = ops::scene::read(&project, id)?;
for image_id in hammer::images::find_ids(&scene.markdown) {
let image = ops::image::get(&project, image_id)?;
hammer::log::info(&format!("{}.{}: {} bytes", image.name, image.extension, image.content.len()));
}
hammer::images::PREFIX
pub const PREFIX: &str = "hammer://image/";
What every image reference starts with.
hammer::images::reference
pub fn reference(image_id: i32) -> String
Returns the reference to the image image_id, such as hammer://image/3, for markdown such as .
hammer::images::parse_reference
pub fn parse_reference(reference: &str) -> Option<i32>
Returns the image id a reference names: hammer://image/3 and <hammer://image/3> both give 3. Returns None for anything else, such as another scheme, or an id that's empty, isn't all ASCII digits, or is too large for an i32.
hammer::images::find_ids
pub fn find_ids(markdown: &str) -> Vec<i32>
Returns every image id that markdown refers to, each once, in the order they first appear. As in Hammer's own count, it finds references in images, in angle-bracket destinations, in reference definitions ([map]: hammer://image/3), and in code alike.
A reference counts only when no letter, digit, _, +, ., or - comes just before hammer://, and no letter, digit, or _ comes just after the id. So hammer://image/12x names no image, and neither does an id too large for an i32.
hammer::images::get
pub fn get(project: &str, reference: &str) -> crate::Result<crate::ops::ImageContent>
Reads the image a reference names, as parse_reference reads it, with its bytes, by calling image.get. Your manifest needs image.get or content:read. If the reference names no image, the error is InvalidInput: Not an image reference: <reference>. If the image isn't in project, the error is image.get's.
hammer::base64
Operations carry binary content, such as an image or an exported file, as base64 text. The typed functions in hammer::ops convert it for you. Use this module when you build or read such JSON yourself.
hammer::base64::encode
pub fn encode(bytes: &[u8]) -> String
Encodes bytes as standard base64, with padding.
hammer::base64::decode
pub fn decode(text: &str) -> Option<Vec<u8>>
Returns the bytes text holds, or None if it isn't base64. Padding may be left out altogether. None is returned for a character outside the standard alphabet, = anywhere but the end, more than two =, too few = to fill the last group of four, or a last group of one character.
hammer::base64::serialize
pub fn serialize<S: Serializer>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error>
Writes bytes as a base64 string, for #[serde(serialize_with = "hammer::base64::serialize")] on a &[u8] field.
hammer::base64::bytes
pub fn serialize<S: Serializer>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error>
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error>
For #[serde(with = "hammer::base64::bytes")] on a Vec<u8> field. Reading a string that isn't base64 fails with invalid base64.
hammer::base64::option
pub fn serialize<S: Serializer>(bytes: &Option<Vec<u8>>, serializer: S) -> Result<S::Ok, S::Error>
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<Vec<u8>>, D::Error>
For #[serde(with = "hammer::base64::option")] on an Option<Vec<u8>> field. None is written and read as null.
hammer::export
The export export renders one of the manifest's [[exporters]] formats from the story, and returns the file's bytes. /reference/exports has the full rules.
#[unsafe(no_mangle)]
pub extern "C" fn export() -> i32 {
hammer::run(|| {
let request: hammer::export::Request = hammer::input_json()?;
let mut text = format!("{}\n", request.project_name);
for chapter in &request.chapters {
text += &format!("{}: {} scenes\n", chapter.name, chapter.scenes.len());
}
Ok(text)
})
}
An exporter whose manifest entry sets images = true also gets the files of the images the story shows, in Request::images. With cover = true as well, it gets the project's cover. In markdown, an image is , which hammer::images reads. In prose, a line holding only an image is a block {"type": "image", "id": 5, "name": "Map"}, which prose_image_id reads. An image inside a line, or one whose file Hammer couldn't read, appears as its name in the text, never as a block.
let request: hammer::export::Request = hammer::input_json()?;
for scene in request.chapters.iter().flat_map(|chapter| &chapter.scenes) {
for id in hammer::images::find_ids(scene) {
if let Some(image) = request.image(id) { /* image.content holds the file */ }
}
}
An exporter whose manifest entry sets output = "archive" returns an Archive instead.
The struct definitions below leave out serde attributes and field doc comments.
hammer::export::Request
#[derive(Deserialize)]
pub struct Request {
pub format: String,
pub project_name: String,
pub language: String,
pub top_level_as_chapters: bool,
pub chapters: Vec<Chapter>,
pub settings: Map<String, Value>,
pub locale: Option<String>,
pub images: Vec<Image>,
pub cover_image_id: Option<i32>,
}
The input that the export export receives. In JSON, the fields are camelCase.
format: which of the manifest's[[exporters]]formats to render.project_name: the project's name.language: the project's BCP 47 tag. It'senif the request has none.top_level_as_chapters: whether the scene tree's top-level nodes are chapters. If not, the story is one chapter. It'strueif the request doesn't say.chapters: the story, chapter by chapter.settings: your plugin's declared settings, with every key present.locale: the UI's BCP 47 tag, so you can reply in the writer's language. It isn't the same aslanguage, which is the story's.images: for a format withimages = true, each project image the story refers to whose file Hammer could read, sorted by id, plus the cover withcover = true. It's empty otherwise.cover_image_id: the cover's id amongimages, withcover = true. It'sNoneif the project has no cover, or its file couldn't be read.
hammer::export::Request::image
pub fn image(&self, id: i32) -> Option<&Image>
Returns the image id among images, or None if it isn't there.
hammer::export::Request::cover
pub fn cover(&self) -> Option<&Image>
Returns the cover among images, or None if there isn't one.
hammer::export::Chapter
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct Chapter {
pub name: String,
pub scenes: Vec<String>,
pub prose: Vec<Vec<Value>>,
}
A chapter and its scenes. Each scene is markdown in scenes, or, for a format whose manifest entry sets input = "prose", a list of Hammer's parsed blocks in prose (see Prose input). The other list is empty.
hammer::export::Image
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct Image {
pub id: i32,
pub name: String,
pub extension: String,
pub mime_type: String,
pub content: Vec<u8>,
}
A project image and its file: image.get's ImageContent with mime_type added. mime_type is application/octet-stream when Hammer doesn't know the extension. content holds the file's bytes; the request carries it as base64, and reading a request whose content isn't base64 fails.
hammer::export::prose_image_id
pub fn prose_image_id(block: &Value) -> Option<i32>
Returns the id of a prose image block, such as {"type": "image", "id": 5, "name": "Map"}. Returns None for any other block.
hammer::export::Archive
#[derive(Default)]
pub struct Archive { /* private fields */ }
What the export export returns for a format whose manifest entry sets output = "archive": files that Hammer zips, in the order you added them, into the one export file. The manifest entry still gives that file's extension and mime (zip and application/zip). Serialize it with Archive::to_json.
let mut archive = hammer::export::Archive::new();
archive.text("story.md", story)?;
for image in &request.images {
archive.image(&format!("images/{}.{}", image.id, image.extension), image)?;
}
archive.to_json()
A path is relative, with / between its segments. It must not be empty, start with /, contain \, have an empty segment (so no // or trailing /), or have a . or .. segment, and it must not be a path already added. Adding a file with any other path fails with an error naming the path, as Hammer would refuse the reply.
Each adding method returns the archive, so you can chain them after ?.
hammer::export::Archive::new
pub fn new() -> Archive
Returns an archive without files.
hammer::export::Archive::text
pub fn text(&mut self, path: impl Into<String>, text: impl Into<String>) -> crate::Result<&mut Archive>
Adds text as the file at path, written as UTF-8.
hammer::export::Archive::bytes
pub fn bytes(&mut self, path: impl Into<String>, bytes: &[u8]) -> crate::Result<&mut Archive>
Adds bytes as the file at path.
hammer::export::Archive::base64
pub fn base64(&mut self, path: impl Into<String>, encoded: impl Into<String>) -> crate::Result<&mut Archive>
Adds the file at path whose bytes encoded holds as standard padded base64. The kit passes encoded on without decoding it, which suits base64 your plugin already has. If it isn't base64, Hammer fails the export.
hammer::export::Archive::image
pub fn image(&mut self, path: impl Into<String>, image: impl ArchiveImage) -> crate::Result<&mut Archive>
Adds an image from Request::images as the file at path, named by its id: pass the id, such as 5, or the Image itself. Hammer writes the image's bytes, so the reply doesn't carry them. If Hammer didn't send an image with that id, the export fails.
hammer::export::Archive::to_json
pub fn to_json(&self) -> crate::Result<String>
Turns the archive into what the export export returns. Return the result from run's body. It fails if no file was added.
hammer::export::ArchiveImage
pub trait ArchiveImage {
fn id(&self) -> i32;
}
impl ArchiveImage for i32
impl ArchiveImage for Image
impl<T: ArchiveImage + ?Sized> ArchiveImage for &T
What Archive::image accepts to name an image: its id, or the Image, owned or borrowed.
hammer::action
The action export adds an item to the menus of the screens listed in the manifest entry's places. If the action has any [[actions.field]]s, Hammer asks the writer to fill them in first, then calls your plugin. Every call returns a Reply as JSON, and the reply decides what happens next:
- A reply with a
Pageshows that page in the action's dialog. When the writer presses one of the page's buttons, Hammer calls the action again with the button's id, the page's values asRequest::input, and the page'sstate. - A reply with
errorskeeps the writer on the page they were on. - A reply with
openends the action and goes to an item. - A reply with none of these ends the action.
In every case, a message is shown briefly. /reference/actions has the full rules.
let page = Page {
title: Some("Who?".into()),
fields: vec![Field::text("name", "Name").required()],
buttons: vec![Button::new("back", "Back").skip_checks(), Button::new("next", "Next").primary()],
..Page::default()
}
.with_state(&Step { step: 2 })?;
Reply::page(page).to_json()
The struct definitions below leave out serde attributes and field doc comments. Optional and empty fields are omitted from the JSON.
hammer::action::Request
#[derive(Deserialize)]
pub struct Request {
pub action: String,
pub project: String,
pub settings: Map<String, Value>,
pub input: Map<String, Value>,
pub context: Context,
pub button: Option<String>,
pub changed: Option<String>,
pub state: Option<Value>,
pub resumed: bool,
pub locale: Option<String>,
}
The input that the action export receives.
action: which of the manifest's[[actions]]to run.project: the project's name.settings: your plugin's declared settings, with every key present.input: a value for each field on the page the writer was on. On the first call, it has the values of the action's[[actions.field]]s.context: where the action was run from.button: the id of the button the writer pressed. It'sNoneon the first call, which is the one that opens the action or runs the manifest's fields.changed: reserved for fields that call the action when they change. It's alwaysNone.state: the last page'sstate, exactly as your plugin wrote it. It'sNoneon the first call.resumed: set on the first call after Hammer restores a run the writer had left.locale: the UI's BCP 47 tag, so you can reply in the writer's language. This isn't the project's language.
hammer::action::Request::input
pub fn input<T: DeserializeOwned>(&self) -> crate::Result<T>
Reads the input into a T, a type with one field for each field on the page.
hammer::action::Request::state
pub fn state<T: DeserializeOwned>(&self) -> Option<T>
Reads the state into a T. Returns None if there's no state, or if it isn't a T.
hammer::action::Context
#[derive(Deserialize)]
pub struct Context {
pub place: String,
pub id: Option<i64>,
}
impl Default for Context
The screen an action was run from, and the id of the item that screen shows. place is project, scene, note, entry, or event. id is None on Project Home. If the request has no context, the default is used: project with no id.
hammer::action::Reply
#[derive(Serialize, Default)]
pub struct Reply {
pub page: Option<Page>,
pub message: Option<String>,
pub errors: BTreeMap<String, String>,
pub open: Option<Open>,
}
What the action export returns, serialized with Reply::to_json. Every part is optional, and the default reply ends the action without showing anything.
page: a page to show in the action's dialog.message: a message shown briefly, whatever else the reply does.errors: error messages keyed by field key. Each message appears under its field, and the writer stays on the page with what they entered. A message under the key"", or under a key that doesn't match any field, is shown for the whole page.open: an item to go to. This ends the action.
Each constructor sets one part. To combine parts, use struct update syntax:
Reply { message: Some("Added".into()), ..Reply::open(Open::new("entry", Some(12))) }
hammer::action::Reply::message
pub fn message(message: impl Into<String>) -> Reply
Ends the action and briefly shows message.
hammer::action::Reply::document
pub fn document(markdown: impl Into<String>) -> Reply
Shows markdown on its own, as a report the writer can copy or save as a note.
hammer::action::Reply::page
pub fn page(page: Page) -> Reply
Shows page in the action's dialog.
hammer::action::Reply::error
pub fn error(key: impl Into<String>, message: impl Into<String>) -> Reply
Keeps the writer on the page and shows message under the field key, or for the whole page if key is "".
hammer::action::Reply::open
pub fn open(open: Open) -> Reply
Ends the action and goes to an item.
hammer::action::Reply::to_json
pub fn to_json(&self) -> crate::Result<String>
Turns the reply into what the action export returns. Return the result from run's body.
hammer::action::Page
#[derive(Serialize, Default)]
pub struct Page {
pub title: Option<String>,
pub markdown: Option<String>,
pub fields: Vec<Field>,
pub buttons: Vec<Button>,
pub state: Option<Value>,
}
A page in the action's dialog. It shows a title, markdown, the fields in order, and buttons.
Hammer never looks inside state. It passes it back to your plugin, unchanged, as the next call's Request::state. If the state is larger than 1 MiB as JSON, the call fails.
hammer::action::Page::with_state
pub fn with_state(mut self, state: &impl Serialize) -> crate::Result<Page>
Returns the page with state as its state. On the next call, read it back with Request::state.
hammer::action::Field
#[derive(Serialize, Clone)]
pub struct Field {
pub key: String,
pub kind: FieldKind,
pub label: String,
pub hint: Option<String>,
pub required: bool,
pub default: Option<Value>,
pub min: Option<i64>,
pub max: Option<i64>,
pub options: Vec<Choice>,
pub format: TextFormat,
pub multiline: bool,
pub kinds: Vec<String>,
pub entry_types: Vec<String>,
pub tags: Vec<String>,
pub multiple: Option<bool>,
}
A field on a page. Its value comes back in the next call's input under the field's key:
textandchoice: a string.bool: a bool.int: an integer.scenes: a list of scene ids, or withsingle, one id or null.entity: anEntityRefor null, or withmultiple, a list of them.
Before calling your plugin, Hammer checks that required fields are filled in, that int values are within their bounds, that choice values are among the options, and that an entity field's picks exist and match its filters. It skips these checks if the pressed button skips them. In JSON, kind is written as type.
To build a field, call one of the constructors below, then chain the modifiers you need.
hammer::action::Field::text
pub fn text(key: impl Into<String>, label: impl Into<String>) -> Field
A text field. It's plain unless you give it a format.
hammer::action::Field::bool
pub fn bool(key: impl Into<String>, label: impl Into<String>) -> Field
A yes-or-no field. Its value is a bool.
hammer::action::Field::int
pub fn int(key: impl Into<String>, label: impl Into<String>, min: Option<i64>, max: Option<i64>) -> Field
An integer field, with optional bounds.
hammer::action::Field::choice
pub fn choice(key: impl Into<String>, label: impl Into<String>, options: Vec<Choice>) -> Field
A choice between options (at least one). Its value is the chosen option's value.
hammer::action::Field::scenes
pub fn scenes(key: impl Into<String>, label: impl Into<String>) -> Field
Scenes picked from the project's tree. Its value is a list, or a single scene with single.
hammer::action::Field::entity
pub fn entity(key: impl Into<String>, label: impl Into<String>) -> Field
An item from the project, picked by name. Its value is one EntityRef or null, or a list with multiple. Use kinds, entry_types, and tags to narrow what the picker offers. If the default no longer exists or doesn't match, Hammer shows the field as empty.
hammer::action::Field::hint
pub fn hint(mut self, hint: impl Into<String>) -> Field
Adds a line of help shown with the field.
hammer::action::Field::required
pub fn required(mut self) -> Field
Makes the field required. A text field must not be blank, a scenes field must not be empty, and an entity field needs a pick.
hammer::action::Field::default
pub fn default(mut self, value: impl Into<Value>) -> Field
Sets the initial value, such as "elvish", 8, true, vec![3, 4], or an EntityRef (a Vec of them for an entity field with multiple). Without a default, each kind of field starts with its own: false, min or 0, the first option, "", or nothing.
hammer::action::Field::format
pub fn format(mut self, format: TextFormat) -> Field
Sets the editor a text field uses; see TextFormat.
hammer::action::Field::multiline
pub fn multiline(mut self) -> Field
Makes a plain text field several lines tall.
hammer::action::Field::single
pub fn single(mut self) -> Field
Makes a scenes field's value one scene, or null, instead of a list. On an entity field, it undoes multiple.
hammer::action::Field::multiple
pub fn multiple(mut self) -> Field
Makes an entity field's value a list of items instead of one. On a scenes field, it undoes single.
hammer::action::Field::kinds
pub fn kinds(mut self, kinds: &[&str]) -> Field
Limits an entity field to these kinds of item: scene, note, entry, or event. If you don't set it, the field offers all four.
hammer::action::Field::entry_types
pub fn entry_types(mut self, types: &[&str]) -> Field
Limits an entity field to these types of entry, when entry is among its kinds: person, place, thing, event, or idea. If you don't set it, the field offers any type.
hammer::action::Field::tags
pub fn tags(mut self, tags: &[&str]) -> Field
Limits an entity field to items that have at least one of these tags. If you don't set it, the field offers any item.
hammer::action::FieldKind
#[derive(Serialize, Clone, Copy, PartialEq, Eq, Debug)]
pub enum FieldKind {
Text,
Bool,
Int,
Choice,
Scenes,
Entity,
}
A field's type, written in lowercase in JSON. The Field constructors set it for you.
hammer::action::TextFormat
#[derive(Serialize, Clone, Copy, PartialEq, Eq, Default, Debug)]
pub enum TextFormat {
#[default]
Plain,
Rich,
RichExtended,
}
The editor a text field uses:
Plain: an ordinary text field.Rich: the scene editor, with its format bar. The value is markdown.RichExtended: the scene editor, with the extended format bar, which has highlight, task lists, and tables. The value is markdown.
In JSON, these are written as plain, rich, and rich-extended.
hammer::action::Choice
#[derive(Serialize, Clone)]
pub struct Choice {
pub value: String,
pub label: String,
}
One of a choice field's options. value is what comes back in the input, and label is what the writer sees.
hammer::action::Choice::new
pub fn new(value: impl Into<String>, label: impl Into<String>) -> Choice
hammer::action::EntityRef
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
pub struct EntityRef {
pub kind: String,
pub id: i64,
}
impl From<EntityRef> for Value
An item in the project, as an entity field's value. kind is scene, note, entry, or event, and id is the item's id.
Read an entity field as Option<EntityRef>, or as Vec<EntityRef> if it uses multiple. To go to the item, reply with Open::new(&it.kind, Some(it.id)). The From impl lets you use an EntityRef as a field's default.
#[derive(Deserialize)]
struct Picks {
who: Option<EntityRef>,
#[serde(default)]
cast: Vec<EntityRef>,
}
let picks: Picks = request.input()?;
hammer::action::EntityRef::new
pub fn new(kind: impl Into<String>, id: i64) -> EntityRef
hammer::action::Button
#[derive(Serialize, Clone)]
pub struct Button {
pub id: String,
pub label: String,
pub primary: bool,
pub inline: bool,
pub validate: bool,
}
A button on a page. Pressing it calls the action again, with the button's id as Request::button.
Buttons sit in the dialog's footer. With inline, a button sits under the markdown instead, for acting on something in it. validate controls whether Hammer checks the page's fields before calling your plugin.
hammer::action::Button::new
pub fn new(id: impl Into<String>, label: impl Into<String>) -> Button
Creates a footer button that isn't primary and does check the fields.
hammer::action::Button::primary
pub fn primary(mut self) -> Button
Marks the button as the page's main button.
hammer::action::Button::inline
pub fn inline(mut self) -> Button
Places the button under the markdown instead of in the footer.
hammer::action::Button::skip_checks
pub fn skip_checks(mut self) -> Button
Makes the button call the action without Hammer checking the fields first. Back and Cancel buttons should do this.
hammer::action::Open
#[derive(Serialize, Clone)]
pub struct Open {
pub place: String,
pub id: Option<i64>,
}
An item for Hammer to go to. place is project, scene, note, entry, or event, and id is the item's id (None for project). An id that doesn't exist fails.
hammer::action::Open::new
pub fn new(place: impl Into<String>, id: Option<i64>) -> Open
hammer::diagnose
Hammer sends paragraphs of a scene to the diagnose export, and your plugin replies with the issues it finds in them. The editor underlines those issues. The check's scope in the manifest decides which paragraphs Hammer sends:
paragraph(the default) sends only paragraphs whose text the check hasn't seen yet.scenesends every paragraph of the scene each time, including blank ones. Use it for issues that depend on other paragraphs.
diagnose::Reply has no to_json method, so serialize it with serde_json:
#[unsafe(no_mangle)]
pub extern "C" fn diagnose() -> i32 {
hammer::run(|| {
let request: Request = hammer::input_json()?;
Ok(serde_json::to_vec(&check(&request))?)
})
}
hammer::diagnose::Request
#[derive(Deserialize)]
pub struct Request {
pub diagnostics: String,
pub paragraphs: Vec<String>,
pub language: Option<String>,
pub project: String,
pub settings: Map<String, Value>,
pub locale: Option<String>,
}
The input that the diagnose export receives.
diagnostics: which of the manifest's[[diagnostics]]checks to run.paragraphs: the paragraphs as plain text, without markdown.language: the project's BCP 47 tag, orNone.project: the name of the project the text belongs to, for operations such asentry.list.settings: your plugin's declared settings, with every key present.locale: the UI's BCP 47 tag, so you can write messages in the writer's language. It isn't the same aslanguage, which is the language of the text.
hammer::diagnose::Reply
#[derive(Serialize, Default)]
pub struct Reply {
pub diagnostics: Vec<Diagnostic>,
}
What the diagnose export returns. The default reports no issues.
hammer::diagnose::Diagnostic
#[derive(Serialize)]
pub struct Diagnostic {
pub paragraph: usize,
pub start: usize,
pub end: usize,
pub message: String,
pub fixes: Vec<Fix>,
pub severity: Severity,
}
An issue in one paragraph. It runs from start to end, given as UTF-8 byte offsets into the paragraph. paragraph is the paragraph's index in Request::paragraphs, and fixes are offered to the writer.
There's no constructor, so write out the struct. For a mistake, set severity: Severity::Error or Default::default(). To turn character positions into byte offsets, use char_byte_offsets.
hammer::diagnose::Severity
#[derive(Serialize, Clone, Copy, PartialEq, Eq, Default, Debug)]
pub enum Severity {
#[default]
Error,
Suggestion,
}
Error marks a mistake and gets a wavy underline. Suggestion marks something the writer may want to change and gets a quieter, dotted underline. Error is omitted from the JSON.
hammer::diagnose::Fix
#[derive(Serialize)]
pub enum Fix {
Replace(String),
Labelled { replacement: String, label: String },
}
A replacement for a Diagnostic's range. The writer sees either the replacement text itself or a label. Use a label when the replacement alone wouldn't say what the fix does, such as when it removes a word. Replace is written as a bare string, and Labelled as an object.
Fix::Labelled { replacement: String::new(), label: "Remove “very”".into() }
hammer::http
HTTP requests, through Extism's http_request. Hammer allows only the hosts that your manifest names under permissions.http, which the writer approves at install. A request to any other host ends the call.
hammer::http::request
pub fn request(method: &str, url: &str, headers: &[(&str, &str)], body: Option<&[u8]>) -> crate::Result<Response>
Sends a request and waits for its response. A response with any status is Ok, so check is_success. A request Hammer refuses, such as to a host the manifest doesn't name, or one with no response, such as when the network is down, ends the call with Hammer's error rather than returning. An empty body isn't sent.
hammer::http::Response
pub struct Response {
pub status: u16,
pub body: Vec<u8>,
}
A response's status code and body.
hammer::http::Response::is_success
pub fn is_success(&self) -> bool
Returns true for a status from 200 to 299.
hammer::http::form
pub fn form(fields: &[(&str, &str)]) -> String
Encodes fields as application/x-www-form-urlencoded form data, such as text=a+b%26c&x=%C3%A9.
hammer::server
Requests to a service on the writer's own Hammer sync server, such as languagetool. Hammer sends them as the signed-in user, so your plugin never holds the writer's credentials. It sends them only to the services your manifest names under permissions.hammerServer:
[permissions]
hammerServer = ["languagetool"]
hammer::server::languagetool has typed functions for the languagetool service. For any other service, use request.
hammer::server::request
pub fn request(
service: &str,
method: &str,
path: &str,
headers: &[(&str, &str)],
body: Option<&[u8]>,
) -> Result<Response, ServerError>
Sends a request to service, at path under it (such as /v2/check), and waits for its response. method is GET or POST. Only the Content-Type and Accept headers are sent, and an empty body isn't sent.
A response with any status is Ok; Response::error says what a failed status means. Err means the request failed before the service replied, and its kind is NotSignedIn, Unavailable, Network, NotPermitted, or Invalid.
hammer::server::Response
pub struct Response {
pub status: u16,
pub body: String,
}
The service's status code and body.
hammer::server::Response::is_success
pub fn is_success(&self) -> bool
Returns true for a status from 200 to 299.
hammer::server::Response::error
pub fn error(&self) -> Option<ServerError>
Returns what a status other than success means, or None on success. Status 429 is RateLimited, any other 4xx is Rejected, and any other status is ServiceFailed.
The error's message is the one the sync server gave for the user, when the body is JSON with a displayMessage. Otherwise it's The service replied <status>, followed by : and up to 200 characters of the body if the body isn't blank.
hammer::server::ServerError
#[derive(Debug)]
pub struct ServerError {
pub kind: ErrorKind,
pub message: String,
}
impl fmt::Display for ServerError
impl std::error::Error for ServerError
Why a request to the sync server failed. It displays as message, and ? turns it into a hammer::Error, so the writer sees the message.
hammer::server::ErrorKind
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ErrorKind {
NotSignedIn,
Unavailable,
Network,
NotPermitted,
Invalid,
RateLimited,
Rejected,
ServiceFailed,
}
The kind of a ServerError. The first five come from Hammer, before any reply:
NotSignedIn: there's no sync account in this workspace, or its session has ended.Unavailable: the sync server doesn't offer the service.Network: the sync server couldn't be reached.NotPermitted: the manifest doesn't name the service.Invalid: a refused method or path, or a reply that isn't text or isn't what the service should send.
The rest are what Response::error makes of a reply's status:
RateLimited: too many requests (429). The sync server allows more after a minute.Rejected: the service refused the request (4xx), such as one that's too large or in a language it doesn't check.ServiceFailed: the service failed, or the sync server couldn't reach it (5xx).
The enum is #[non_exhaustive], so a match on it needs a _ arm.
hammer::server::languagetool
The sync server's languagetool service, which forwards to the LanguageTool server its admin set up. Your manifest names it under permissions.hammerServer, as in hammer::server.
use hammer::server::languagetool::{self, CheckRequest};
let checked = languagetool::check(&CheckRequest::new(text, "en-US"))?;
for found in &checked.matches {
if let Some(range) = found.byte_range(text) {
hammer::log::info(&format!("{}: {}", &text[range], found.message));
}
}
The response struct definitions below leave out serde attributes and field doc comments. Every field except Match::offset, Match::length, Replacement::value, RuleUrl::value, and Language's fields is filled in with its default when LanguageTool leaves it out.
hammer::server::languagetool::check
pub fn check(request: &CheckRequest) -> Result<CheckResponse, ServerError>
Checks request's text on the sync server's LanguageTool. It posts request.form() to CHECK_PATH. A failed status is an error, as Response::error describes. A reply that can't be read is an Invalid error.
hammer::server::languagetool::languages
pub fn languages() -> Result<Vec<Language>, ServerError>
Returns the languages the sync server's LanguageTool checks, from LANGUAGES_PATH.
hammer::server::languagetool::CheckRequest
#[derive(Debug, Clone, Default, PartialEq)]
pub struct CheckRequest {
pub text: String,
pub language: String,
pub mother_tongue: Option<String>,
pub preferred_variants: Vec<String>,
pub enabled_rules: Vec<String>,
pub disabled_rules: Vec<String>,
pub enabled_categories: Vec<String>,
pub disabled_categories: Vec<String>,
pub enabled_only: bool,
pub level: Level,
}
A check of text.
language: a language code such asen-US, orautofor LanguageTool to detect it. A blank language is sent asauto.mother_tongue: the writer's first language, for rules about false friends.preferred_variants: withauto, the variants to prefer, such asen-GBandde-AT.enabled_rules,disabled_rules,enabled_categories,disabled_categories: LanguageTool's ids for rules (such asEN_A_VS_AN) and categories (such asTYPOS).enabled_only: check only the enabled rules and categories, none of the rest.level: seeLevel.
Set the options with struct update syntax:
let request = CheckRequest { disabled_rules: vec!["WHITESPACE_RULE".into()], ..CheckRequest::new(text, "auto") };
hammer::server::languagetool::CheckRequest::new
pub fn new(text: impl Into<String>, language: impl Into<String>) -> CheckRequest
A check of text in language, with every option at its default.
hammer::server::languagetool::CheckRequest::form
pub fn form(&self) -> String
Returns the URL-encoded form LanguageTool reads. It always has text and language. The other fields are added only when set, with each list joined by commas.
hammer::server::languagetool::Level
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Level {
#[default]
Default,
Picky,
}
Picky turns on more checks of style and typography.
hammer::server::languagetool::CheckResponse
#[derive(Debug, Clone, Default, Deserialize)]
pub struct CheckResponse {
pub language: Option<CheckedLanguage>,
pub matches: Vec<Match>,
pub warnings: Warnings,
}
pub struct Warnings {
pub incomplete_results: bool,
}
pub struct CheckedLanguage {
pub name: String,
pub code: String,
pub detected_language: Option<DetectedLanguage>,
}
pub struct DetectedLanguage {
pub name: String,
pub code: String,
pub confidence: Option<f64>,
}
What a check found. language is the language the text was checked in, with the one LanguageTool detected in it. warnings.incomplete_results is set when LanguageTool stopped before the end of the text, as when a check takes too long. Warnings, CheckedLanguage, and DetectedLanguage derive the same traits as CheckResponse.
hammer::server::languagetool::Match
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Match {
pub message: String,
pub short_message: String,
pub offset: usize,
pub length: usize,
pub replacements: Vec<Replacement>,
pub context: Context,
pub sentence: String,
pub rule: Rule,
}
A problem LanguageTool found. offset and length count UTF-16 code units of the text that was checked, as Java does, not bytes. Use byte_range to get the bytes.
hammer::server::languagetool::Match::byte_range
pub fn byte_range(&self, text: &str) -> Option<Range<usize>>
Returns the match's byte range in text, the text that was checked. Returns None if the match runs past the end of the text, or starts or ends inside a character.
hammer::server::languagetool::Replacement
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Replacement {
pub value: String,
pub short_description: Option<String>,
}
A suggested replacement for a match's range.
hammer::server::languagetool::Context
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Context {
pub text: String,
pub offset: usize,
pub length: usize,
}
Some of the text around a match, with the match's place in it, in UTF-16 code units.
hammer::server::languagetool::Rule
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Rule {
pub id: String,
pub sub_id: Option<String>,
pub description: String,
pub issue_type: String,
pub category: Category,
pub urls: Vec<RuleUrl>,
}
pub struct RuleUrl {
pub value: String,
}
The rule a match broke. issue_type is a value such as misspelling, grammar, style, typographical, or register. Each of urls is a page explaining the rule. RuleUrl derives the same traits as Rule.
hammer::server::languagetool::Category
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Category {
pub id: String,
pub name: String,
}
A rule's category. id is a value such as TYPOS, GRAMMAR, STYLE, or REDUNDANCY.
hammer::server::languagetool::Language
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Language {
pub name: String,
pub code: String,
pub long_code: String,
}
A language the server checks. long_code is the code with its variant, such as en-US, which a check takes as its language.
hammer::server::languagetool::SERVICE
pub const SERVICE: &str = "languagetool";
The service's name, as your manifest lists it under permissions.hammerServer.
hammer::server::languagetool::CHECK_PATH
pub const CHECK_PATH: &str = "/v2/check";
hammer::server::languagetool::LANGUAGES_PATH
pub const LANGUAGES_PATH: &str = "/v2/languages";
hammer::server::languagetool::DEFAULT_MAX_REQUEST_BYTES
pub const DEFAULT_MAX_REQUEST_BYTES: usize = 100_000;
The largest a check's encoded form may be on a sync server left at its defaults. An admin may allow more. Encoding can triple text that isn't ASCII, so keep a check's text to about 30,000 bytes.
What the kit imports
The kit's functions call Hammer through these imports, which /reference/host describes. Your plugin doesn't call them directly.
extism:host/env: the call's input, output, and error, kernel memory, logging, andhttp_requestandhttp_status_code(http::request).extism:host/user:hammer_dispatch(call),hammer_cache_getandhammer_cache_set(cache_get,cache_set),hammer_progress(progress),hammer_resource(resource), andhammer_server_request(server::request).wasi_snapshot_preview1.random_get: only with thegetrandomfeature.
The Kotlin and C kits cover the same ground: /reference/kotlin, /reference/c.