Files
vectorcraft/crates/plugins/src/wat.rs
T
dotCooCoo 1c676fc372 Plug-ins: API level 2 lets a plug-in report progress, see Cancel and write log lines
A plug-in whose manifest gives "api": 2 and lists the new `progress`
permission may import three host functions from module `env`:
vc_progress(done, total), vc_cancelled() -> i32 and
vc_log(level, ptr, len) -> i32. Installing checks every import and
refuses, by name, one the host doesn't offer, one with another type and
one the manifest doesn't allow.

- Progress is clamped and passed on once per tenth of a percent (past
  2,000 reports, once per 1,000 changes). A heavy filter's progress
  window and the export's progress window show it as a percentage bar,
  and their Cancel makes vc_cancelled return 1. A live effect's progress
  is not shown.
- A run cancelled before it returns ends as cancelled, and one that
  returns after its time budget ran out ends at that budget, so what a
  plug-in returns after it sees vc_cancelled changes nothing. This also
  applies to a plug-in of API level 1 that finishes after a Cancel or
  past its time budget.
- Log text must lie in the module's memory and be UTF-8. Lines go to the
  app's log as "<id>: <text>" with hidden characters escaped, at most
  1,024 characters a line, 200 lines and 32 KiB a run, and the host reads
  at most 1 MiB of log text a run.
- plugin.list and plugin.info give the manifest's `api`; the install
  prompt names the new permission in plain words.

Docs: docs/plugins.md, docs/mcp.md, docs/target-app-parity.md.
2026-11-08 13:21:15 +07:00

233 lines
12 KiB
Rust

//! Minimal plug-ins in WebAssembly text (WAT): the skeleton `docs/plugins.md` describes, which the
//! tests turn into modules (with the `wat` crate) to exercise the host.
/// Where [`returning`] keeps its output in memory (the manifest sits at 16).
const OUTPUT_AT: usize = 0x1_0000;
/// `s` as the body of a WAT string literal (every byte that isn't a letter, digit or space
/// escaped).
pub fn string(s: &str) -> String {
s.bytes().map(|b| if b.is_ascii_alphanumeric() || b == b' ' { char::from(b).to_string() } else { format!("\\{b:02x}") }).collect()
}
/// A plug-in module: `manifest` (JSON), a `vc_run` whose body is `run` (parameters `$in $il $pp
/// $pl`, result i64), extra module fields `extra` and `pages` of initial memory. `vc_alloc` grows
/// memory by whole pages, so its blocks never overlap data segments.
pub fn module(manifest: &str, run: &str, extra: &str, pages: u32) -> String {
format!(
r#"(module
{extra}
(memory (export "memory") {pages})
(data (i32.const 16) "{data}")
(func (export "vc_abi_version") (result i32) (i32.const 1))
(func (export "vc_manifest") (result i64) (i64.or (i64.shl (i64.const {len}) (i64.const 32)) (i64.const 16)))
(func (export "vc_alloc") (param $n i32) (result i32) (local $old i32)
(local.set $old (memory.grow (i32.shr_u (i32.add (local.get $n) (i32.const 65535)) (i32.const 16))))
(if (result i32) (i32.eq (local.get $old) (i32.const -1)) (then (i32.const 0)) (else (i32.mul (local.get $old) (i32.const 65536)))))
(func (export "vc_run") (param $in i32) (param $il i32) (param $pp i32) (param $pl i32) (result i64)
{run}))"#,
data = string(manifest),
len = manifest.len(),
)
}
/// A `vc_run` body returning the input unchanged.
pub const ECHO: &str = "(i64.or (i64.shl (i64.extend_i32_u (local.get $il)) (i64.const 32)) (i64.extend_i32_u (local.get $in)))";
/// A plug-in that always returns `output` (whatever it is given).
pub fn returning(manifest: &str, output: &str) -> String {
then_returning(manifest, "", output)
}
/// A plug-in whose `vc_run` runs `first` (instructions, with the locals it declares first), then
/// returns `output`.
fn then_returning(manifest: &str, first: &str, output: &str) -> String {
with_imports(manifest, "", first, output)
}
/// A plug-in with the module fields `imports` before the others, whose `vc_run` runs `first`, then
/// returns `output`.
pub fn with_imports(manifest: &str, imports: &str, first: &str, output: &str) -> String {
let run = format!("{first} (i64.or (i64.shl (i64.const {}) (i64.const 32)) (i64.const {OUTPUT_AT}))", output.len());
let extra = format!(r#"{imports} (data (i32.const {OUTPUT_AT}) "{}")"#, string(output));
module(manifest, &run, &extra, (OUTPUT_AT + output.len()).div_ceil(0x1_0000) as u32 + 1)
}
/// The host imports of plug-in API level 2 ([`crate::host::IMPORTS`]), as `$vc_progress`,
/// `$vc_cancelled` and `$vc_log`.
pub const HOST_IMPORTS: &str = r#"(import "env" "vc_progress" (func $vc_progress (param i64 i64)))
(import "env" "vc_cancelled" (func $vc_cancelled (result i32)))
(import "env" "vc_log" (func $vc_log (param i32 i32 i32) (result i32)))"#;
/// A plug-in of API level 2 (its manifest gives `api` 2 and lists `progress`) that imports the
/// host functions ([`HOST_IMPORTS`]) and whose `vc_run` runs `first`, which may call them, then
/// returns `output`.
pub fn reporting(manifest: &str, first: &str, output: &str) -> String {
with_imports(manifest, HOST_IMPORTS, first, output)
}
/// A plug-in of API level 2 that counts `steps` down, reporting each step's progress and checking
/// `vc_cancelled` before it: when that says the run was cancelled it returns `stopped`, else
/// `output` once the steps are done.
pub fn cooperating(manifest: &str, steps: u64, stopped: &str, output: &str) -> String {
let first = format!(
r#"(local $n i64) (local.set $n (i64.const {steps}))
(block $done (loop $step
(br_if $done (i64.le_s (local.get $n) (i64.const 0)))
(call $vc_progress (i64.sub (i64.const {steps}) (local.get $n)) (i64.const {steps}))
(if (call $vc_cancelled) (then (return (i64.or (i64.shl (i64.const {len}) (i64.const 32)) (i64.const {STOPPED_AT})))))
(local.set $n (i64.sub (local.get $n) (i64.const 1)))
(br $step)))"#,
len = stopped.len(),
);
let imports = format!(r#"{HOST_IMPORTS} (data (i32.const {STOPPED_AT}) "{}")"#, string(stopped));
with_imports(manifest, &imports, &first, output)
}
/// Where [`cooperating`] keeps what it returns when it is cancelled.
const STOPPED_AT: usize = 0x8000;
/// A plug-in that counts `steps` down before it returns `output`. Each step costs a few
/// instructions, so a test picks work that a light budget can't finish and a heavy one can, or
/// that runs until it is cancelled.
pub fn spinning(manifest: &str, steps: u64, output: &str) -> String {
let spin = format!(
"(local $n i64) (local.set $n (i64.const {steps}))
(block $done (loop $step
(br_if $done (i64.le_s (local.get $n) (i64.const 0)))
(local.set $n (i64.sub (local.get $n) (i64.const 1)))
(br $step)))"
);
then_returning(manifest, &spin, output)
}
/// What [`when_given`] looks through.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Given {
/// The input JSON (the objects).
Input,
/// The parameters JSON, `_context` included.
Params,
}
/// Where [`when_given`] keeps the text it looks for.
const NEEDLE_AT: usize = 0x8000;
/// A plug-in that returns `output` when what it is given (`given`) holds the text `needle`, and
/// its input unchanged otherwise. A live effect made from it draws one shape or the other, so a
/// test sees from the geometry whether the host handed the plug-in something.
pub fn when_given(manifest: &str, given: Given, needle: &str, output: &str) -> String {
let (src, len) = match given {
Given::Input => ("$in", "$il"),
Given::Params => ("$pp", "$pl"),
};
let n = needle.len();
// For each place `$i` the needle fits, compare it byte by byte (`$j`).
let run = format!(
r#"(local $i i32) (local $j i32)
(block $found
(block $none
(loop $at
(br_if $none (i32.gt_u (i32.add (local.get $i) (i32.const {n})) (local.get {len})))
(local.set $j (i32.const 0))
(block $next
(loop $cmp
(br_if $found (i32.eq (local.get $j) (i32.const {n})))
(br_if $next (i32.ne (i32.load8_u (i32.add (local.get {src}) (i32.add (local.get $i) (local.get $j))))
(i32.load8_u (i32.add (i32.const {NEEDLE_AT}) (local.get $j)))))
(local.set $j (i32.add (local.get $j) (i32.const 1)))
(br $cmp)))
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br $at)))
(return {ECHO}))
(i64.or (i64.shl (i64.const {out}) (i64.const 32)) (i64.const {OUTPUT_AT}))"#,
out = output.len(),
);
let extra = format!(r#"(data (i32.const {NEEDLE_AT}) "{}") (data (i32.const {OUTPUT_AT}) "{}")"#, string(needle), string(output));
module(manifest, &run, &extra, (OUTPUT_AT + output.len()).div_ceil(0x1_0000) as u32 + 1)
}
/// The manifest of a test plug-in `id` of `kind` with `params` (a JSON object of specs).
pub fn manifest(id: &str, kind: &str, params: &str) -> String {
format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"{kind}","params":{params}}}"#)
}
/// The manifest of a test exporter `id` writing `extensions` (a JSON list) with `params`.
pub fn exporter(id: &str, extensions: &str, params: &str) -> String {
format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"exporter","extensions":{extensions},"params":{params}}}"#)
}
/// What [`echo_exporter`] writes as its file.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Echo {
/// The input: the document, as the host hands it over.
Input,
/// The parameters JSON, `_context` included.
Params,
}
/// Where [`echo_exporter`] keeps the base64 alphabet, then its output's opening and closing text.
const ALPHABET_AT: usize = 0x8000;
/// An exporter (manifest `manifest`) whose file is exactly what it is given (`echo`), so a test
/// reads back what the host hands a plug-in: it returns `{"dataBase64": "…"}`, encoding the bytes
/// in base64 itself, three bytes to four characters, the last group padded with `=`.
pub fn echo_exporter(manifest: &str, echo: Echo) -> String {
let (src, len) = match echo {
Echo::Input => ("$in", "$il"),
Echo::Params => ("$pp", "$pl"),
};
let (open, close) = (r#"{"dataBase64":""#, r#""}"#);
let (open_at, close_at) = (ALPHABET_AT + 64, ALPHABET_AT + 96);
// One character of the alphabet: the six bits of `$b` from `shift` up.
let char_at = |shift: u32| {
format!("(i32.load8_u (i32.add (i32.const {ALPHABET_AT}) (i32.and (i32.shr_u (local.get $b) (i32.const {shift})) (i32.const 63))))")
};
let byte = |k: u32| format!("(i32.load8_u (i32.add (local.get {src}) (i32.add (local.get $i) (i32.const {k}))))");
let run = format!(
r#"(local $o i32) (local $n i32) (local $w i32) (local $i i32) (local $b i32) (local $rest i32)
(local.set $n (i32.add (i32.const {frame}) (i32.mul (i32.const 4) (i32.div_u (i32.add (local.get {len}) (i32.const 2)) (i32.const 3)))))
(local.set $o (memory.grow (i32.add (i32.shr_u (local.get $n) (i32.const 16)) (i32.const 1))))
(if (i32.eq (local.get $o) (i32.const -1)) (then (return (i64.const -1))))
(local.set $o (i32.mul (local.get $o) (i32.const 65536)))
(memory.copy (local.get $o) (i32.const {open_at}) (i32.const {open_len}))
(local.set $w (i32.add (local.get $o) (i32.const {open_len})))
(block $done (loop $triple
(br_if $done (i32.gt_u (i32.add (local.get $i) (i32.const 3)) (local.get {len})))
(local.set $b (i32.or (i32.or (i32.shl {b0} (i32.const 16)) (i32.shl {b1} (i32.const 8))) {b2}))
(i32.store8 (local.get $w) {c18}) (i32.store8 offset=1 (local.get $w) {c12})
(i32.store8 offset=2 (local.get $w) {c6}) (i32.store8 offset=3 (local.get $w) {c0})
(local.set $w (i32.add (local.get $w) (i32.const 4)))
(local.set $i (i32.add (local.get $i) (i32.const 3)))
(br $triple)))
(local.set $rest (i32.sub (local.get {len}) (local.get $i)))
(if (i32.ne (local.get $rest) (i32.const 0)) (then
(local.set $b (i32.shl {b0} (i32.const 16)))
(if (i32.eq (local.get $rest) (i32.const 2)) (then (local.set $b (i32.or (local.get $b) (i32.shl {b1} (i32.const 8))))))
(i32.store8 (local.get $w) {c18}) (i32.store8 offset=1 (local.get $w) {c12})
(i32.store8 offset=2 (local.get $w) (if (result i32) (i32.eq (local.get $rest) (i32.const 2)) (then {c6}) (else (i32.const 61))))
(i32.store8 offset=3 (local.get $w) (i32.const 61))
(local.set $w (i32.add (local.get $w) (i32.const 4)))))
(memory.copy (local.get $w) (i32.const {close_at}) (i32.const {close_len}))
(i64.or (i64.shl (i64.extend_i32_u (local.get $n)) (i64.const 32)) (i64.extend_i32_u (local.get $o)))"#,
frame = open.len() + close.len(),
open_len = open.len(),
close_len = close.len(),
b0 = byte(0),
b1 = byte(1),
b2 = byte(2),
c18 = char_at(18),
c12 = char_at(12),
c6 = char_at(6),
c0 = char_at(0),
);
let extra = format!(
r#"(data (i32.const {ALPHABET_AT}) "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/")
(data (i32.const {open_at}) "{}")
(data (i32.const {close_at}) "{}")"#,
string(open),
string(close)
);
module(manifest, &run, &extra, 1)
}