Skip to main content

hammer/
lib.rs

1//! Hammer's plugin kit for Rust. See hammer-editor's docs/CLIENT-PLUGINS.md for the exports a plugin
2//! provides (`export`, `action`, `command`, `diagnose`) and the JSON each one is given, which always
3//! includes `locale`, the UI's language tag, for replying in the user's language. [action] and
4//! [diagnose] have types for theirs.
5//!
6//! An export is a `#[unsafe(no_mangle)] pub extern "C" fn` returning `i32`, whose body is [run]:
7//!
8//! ```ignore
9//! #[unsafe(no_mangle)]
10//! pub extern "C" fn action() -> i32 {
11//!     hammer::run(|| {
12//!         let request: Request = hammer::input_json()?;
13//!         Reply::message(format!("Hello, {}", request.project)).to_json()
14//!     })
15//! }
16//! ```
17
18use serde::Serialize;
19use serde::de::DeserializeOwned;
20use std::alloc::{GlobalAlloc, Layout, System};
21use std::sync::Once;
22
23pub mod action;
24pub mod base64;
25pub mod diagnose;
26pub mod http;
27pub mod ops;
28#[cfg(test)]
29mod ops_tests;
30
31/// The host API version this kit targets; the manifest's `api` must match.
32pub const API_VERSION: u32 = 1;
33
34pub const CACHE_MAX_KEY: usize = 1024;
35pub const CACHE_MAX_VALUE: usize = 1024 * 1024;
36
37// Extism's kernel keeps the call's input, output, and any memory passed to or from Hammer. Offsets are
38// into the kernel's memory, not the module's.
39#[link(wasm_import_module = "extism:host/env")]
40unsafe extern "C" {
41    fn input_length() -> u64;
42    fn input_load_u8(index: u64) -> u8;
43    fn input_load_u64(index: u64) -> u64;
44    fn alloc(length: u64) -> u64;
45    fn free(offset: u64);
46    fn length(offset: u64) -> u64;
47    fn load_u8(offset: u64) -> u8;
48    fn load_u64(offset: u64) -> u64;
49    fn store_u8(offset: u64, value: u8);
50    fn store_u64(offset: u64, value: u64);
51    fn output_set(offset: u64, length: u64);
52    fn error_set(offset: u64);
53    fn log_debug(offset: u64);
54    fn log_info(offset: u64);
55    fn log_warn(offset: u64);
56    fn log_error(offset: u64);
57}
58
59#[link(wasm_import_module = "extism:host/user")]
60unsafe extern "C" {
61    fn hammer_dispatch(request: u64) -> u64;
62    fn hammer_cache_get(key: u64) -> u64;
63    fn hammer_cache_set(key: u64, value: u64);
64    fn hammer_progress(report: u64);
65    fn hammer_resource(name: u64) -> u64;
66}
67
68fn to_kernel(bytes: &[u8]) -> u64 {
69    unsafe {
70        let offset = alloc(bytes.len() as u64);
71        let mut chunks = bytes.chunks_exact(8);
72        let mut at = offset;
73        for chunk in &mut chunks {
74            store_u64(at, u64::from_le_bytes(chunk.try_into().unwrap()));
75            at += 8;
76        }
77        for &byte in chunks.remainder() {
78            store_u8(at, byte);
79            at += 1;
80        }
81        offset
82    }
83}
84
85fn from_kernel(offset: u64) -> Vec<u8> {
86    unsafe {
87        let size = length(offset) as usize;
88        let mut bytes = Vec::with_capacity(size);
89        let mut at = 0;
90        while at + 8 <= size {
91            bytes.extend_from_slice(&load_u64(offset + at as u64).to_le_bytes());
92            at += 8;
93        }
94        while at < size {
95            bytes.push(load_u8(offset + at as u64));
96            at += 1;
97        }
98        bytes
99    }
100}
101
102/// A failure to report to the user. `?` converts any error into one, and `.into()` a message.
103pub type Error = Box<dyn std::error::Error>;
104
105pub type Result<T> = std::result::Result<T, Error>;
106
107/// Runs an export's body: its output on success, its message on failure, and a panic's message too.
108pub fn run<O: AsRef<[u8]>>(body: impl FnOnce() -> Result<O>) -> i32 {
109    static PANIC_HOOK: Once = Once::new();
110    // A panic aborts the call with a trap; the message set here is what the user sees.
111    PANIC_HOOK.call_once(|| std::panic::set_hook(Box::new(|info| set_error(&info.to_string()))));
112    match body() {
113        Ok(output) => {
114            let output = output.as_ref();
115            unsafe { output_set(to_kernel(output), output.len() as u64) };
116            0
117        }
118        Err(error) => {
119            set_error(&error.to_string());
120            1
121        }
122    }
123}
124
125fn set_error(message: &str) {
126    unsafe { error_set(to_kernel(message.as_bytes())) };
127}
128
129pub fn input() -> Vec<u8> {
130    unsafe {
131        let size = input_length() as usize;
132        let mut bytes = Vec::with_capacity(size);
133        let mut at = 0;
134        while at + 8 <= size {
135            bytes.extend_from_slice(&input_load_u64(at as u64).to_le_bytes());
136            at += 8;
137        }
138        while at < size {
139            bytes.push(input_load_u8(at as u64));
140            at += 1;
141        }
142        bytes
143    }
144}
145
146pub fn input_json<T: DeserializeOwned>() -> Result<T> {
147    Ok(serde_json::from_slice(&input())?)
148}
149
150#[derive(serde::Deserialize)]
151struct Reply {
152    #[serde(default)]
153    output: serde_json::Value,
154    error: Option<ReplyError>,
155}
156
157#[derive(serde::Deserialize)]
158struct ReplyError {
159    kind: String,
160    #[serde(default)]
161    message: String,
162}
163
164/// Runs a Hammer operation the manifest has permission for, such as
165/// `call::<Tree>("scene.tree", &json!({"project": name}))`. A failure's message starts with its kind:
166/// NotFound, InvalidInput, PermissionDenied, or Failed.
167pub fn call<T: DeserializeOwned>(operation: &str, input: &impl Serialize) -> Result<T> {
168    #[derive(Serialize)]
169    struct Request<'a, I> {
170        operation: &'a str,
171        input: &'a I,
172    }
173    let request = to_kernel(&serde_json::to_vec(&Request { operation, input })?);
174    let reply = unsafe { hammer_dispatch(request) };
175    unsafe { free(request) };
176    let bytes = from_kernel(reply);
177    unsafe { free(reply) };
178    let reply: Reply = serde_json::from_slice(&bytes)?;
179    match reply.error {
180        Some(error) => Err(format!("{}: {}", error.kind, error.message).into()),
181        // An output of null is still a success.
182        None => Ok(serde_json::from_value(reply.output)?),
183    }
184}
185
186// The plugin's own cache, kept by Hammer between calls and runs. Hammer may drop any value at any
187// time, and clears them all when the plugin is installed again or uninstalled. A call still running on
188// the old version during an install may write after the clear, so put a version in keys whose values'
189// format may change.
190
191/// The value cached under `key`, or None when there is none.
192pub fn cache_get(key: &str) -> Option<Vec<u8>> {
193    if key.len() > CACHE_MAX_KEY {
194        return None;
195    }
196    let key = to_kernel(key.as_bytes());
197    let value = unsafe { hammer_cache_get(key) };
198    unsafe { free(key) };
199    if value == 0 {
200        return None;
201    }
202    let bytes = from_kernel(value);
203    unsafe { free(value) };
204    Some(bytes)
205}
206
207/// Caches `value` under `key`; an empty value removes it. False, storing nothing, when either is over
208/// its limit.
209pub fn cache_set(key: &str, value: &[u8]) -> bool {
210    if key.len() > CACHE_MAX_KEY || value.len() > CACHE_MAX_VALUE {
211        return false;
212    }
213    let key = to_kernel(key.as_bytes());
214    let value = if value.is_empty() { 0 } else { to_kernel(value) };
215    unsafe {
216        hammer_cache_set(key, value);
217        free(key);
218        if value != 0 {
219            free(value);
220        }
221    }
222    true
223}
224
225/// Reads a file from the package's `resources/` by its path there, such as `words/en.txt`: None when
226/// the package has none by that name, or it is empty. Hammer reads it from the package on every call,
227/// so keep one you use often.
228pub fn resource(name: &str) -> Option<Vec<u8>> {
229    let name = to_kernel(name.as_bytes());
230    let value = unsafe { hammer_resource(name) };
231    unsafe { free(name) };
232    if value == 0 {
233        return None;
234    }
235    let bytes = from_kernel(value);
236    unsafe { free(value) };
237    Some(bytes)
238}
239
240/// Reports how far an action has got: `fraction` from 0 to 1 when known, and a line saying what it is
241/// doing. Hammer shows it while the action runs. When the user has stopped the run, the call does not
242/// return: the action ends there.
243pub fn progress(fraction: Option<f64>, message: Option<&str>) {
244    #[derive(Serialize)]
245    struct Report<'a> {
246        #[serde(skip_serializing_if = "Option::is_none")]
247        fraction: Option<f64>,
248        #[serde(skip_serializing_if = "Option::is_none")]
249        message: Option<&'a str>,
250    }
251    let fraction = fraction.map(|it| it.clamp(0.0, 1.0));
252    let Ok(report) = serde_json::to_vec(&Report { fraction, message }) else { return };
253    let handle = to_kernel(&report);
254    unsafe {
255        hammer_progress(handle);
256        free(handle);
257    }
258}
259
260/// The UTF-8 byte offset of each character of `text`, plus one for its end, so a range counted in
261/// characters becomes the byte range Hammer expects.
262pub fn char_byte_offsets(text: &str) -> Vec<usize> {
263    text.char_indices().map(|(at, _)| at).chain(std::iter::once(text.len())).collect()
264}
265
266/// An allocator that zeroes memory as it is freed. A module pre-initialized with Wizer keeps only its
267/// memory's non-zero bytes, so with this it carries what start-up kept, not what it threw away.
268///
269/// ```ignore
270/// #[global_allocator]
271/// static ALLOCATOR: hammer::ZeroingAllocator = hammer::ZeroingAllocator;
272/// ```
273pub struct ZeroingAllocator;
274
275unsafe impl GlobalAlloc for ZeroingAllocator {
276    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
277        unsafe { System.alloc(layout) }
278    }
279
280    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
281        unsafe {
282            ptr.write_bytes(0, layout.size());
283            System.dealloc(ptr, layout)
284        }
285    }
286}
287
288#[cfg(feature = "getrandom")]
289mod random {
290    #[link(wasm_import_module = "wasi_snapshot_preview1")]
291    unsafe extern "C" {
292        fn random_get(buf: *mut u8, len: usize) -> u16;
293    }
294
295    /// getrandom's backend, from the one WASI function Hammer offers for it. Needs
296    /// `--cfg getrandom_backend="custom"`, which this workspace's `.cargo/config.toml` sets.
297    #[unsafe(no_mangle)]
298    unsafe extern "Rust" fn __getrandom_v03_custom(dest: *mut u8, len: usize) -> Result<(), getrandom::Error> {
299        match unsafe { random_get(dest, len) } {
300            0 => Ok(()),
301            _ => Err(getrandom::Error::UNSUPPORTED),
302        }
303    }
304}
305
306pub mod log {
307    fn send(to: unsafe extern "C" fn(u64), message: &str) {
308        let handle = super::to_kernel(message.as_bytes());
309        unsafe {
310            to(handle);
311            super::free(handle);
312        }
313    }
314
315    pub fn debug(message: &str) {
316        send(super::log_debug, message)
317    }
318
319    pub fn info(message: &str) {
320        send(super::log_info, message)
321    }
322
323    pub fn warn(message: &str) {
324        send(super::log_warn, message)
325    }
326
327    pub fn error(message: &str) {
328        send(super::log_error, message)
329    }
330}