Skip to main content

jasper_plugin_sdk/
lib.rs

1//! # jasper-plugin-sdk
2//!
3//! Jasper 后端插件(wasm,spec 0.3)的 Rust SDK:封装 ABI(spec §6),
4//! 作者只写业务函数/类型,用 [`register!`] 一行接入。
5//!
6//! ```ignore
7//! use jasper_plugin_sdk as sdk;
8//! use sdk::core::model::Note;
9//!
10//! fn before_save(mut note: Note) -> Result<Note, String> {
11//!     note.body = note.body.lines().map(|l| l.trim_end()).collect::<Vec<_>>().join("\n");
12//!     Ok(note)
13//! }
14//!
15//! sdk::register! { before_save: before_save }
16//! // 或:sdk::register! { storage: MyStorage }(impl sdk::storage::Storage)
17//! // 或组合任意子集:sdk::register! { before_save: before_save, command: run, ui: render }
18//! ```
19//!
20//! 插件 crate 须为 `crate-type = ["cdylib"]`,目标 `wasm32-unknown-unknown`。
21
22pub mod host;
23#[cfg(all(not(target_arch = "wasm32"), feature = "native-host"))]
24pub mod native_host;
25pub mod rt;
26pub mod storage;
27
28pub use jasper_core as core;
29pub use rt::PluginError;
30// 宏与作者代码共用同一版本 serde_json
31pub use serde_json;
32
33// wasmi 沙箱无熵源:给 getrandom 注册报错实现(仅为编译通过)。
34// 插件不该自造 Joplin id——id 由宿主生成;误调 core::serialize::new_id 会 panic 该次调用(宿主可容错)。
35#[cfg(target_arch = "wasm32")]
36mod rand_shim {
37    fn no_entropy(_buf: &mut [u8]) -> Result<(), getrandom::Error> {
38        Err(getrandom::Error::UNSUPPORTED)
39    }
40    getrandom::register_custom_getrandom!(no_entropy);
41}
42
43/// 生成 `plugin_alloc` / `plugin_free` / `plugin_dispatch` 三个 ABI 导出(spec §6.1/§6.2)。
44/// 可挂载(任意顺序、可组合):
45/// - `before_save`:`fn(Note) -> Result<Note, String>`
46/// - `storage`:impl [`storage::Storage`] 的类型
47/// - `command`:`fn(&str /* 命令 id */, Value /* args */) -> Result<Value, PluginError>`
48/// - `ui`:`fn(&str /* view */, Value /* state */) -> Result<Value, PluginError>`(返回 UiNode 树,spec §9.3)
49/// - `editor`:`fn(&str /* phase: before-save|input */, String /* text */) -> Result<String, PluginError>`
50///   (编辑期文本变换,`contributes.editor` → `editor.transform`,spec §3.7/§6.5)
51#[macro_export]
52macro_rules! register {
53    ( $($rest:tt)* ) => {
54        $crate::__register_accum! { hook = (), storage = (), command = (), ui = (), editor = (); $($rest)* }
55    };
56}
57
58// 累积器:按键收集五个可选槽位,与书写顺序无关。
59#[doc(hidden)]
60#[macro_export]
61macro_rules! __register_accum {
62    ( hook = ($($h:path)?), storage = ($($s:ty)?), command = ($($c:path)?), ui = ($($u:path)?), editor = ($($e:path)?); before_save: $f:path $(, $($rest:tt)*)? ) => {
63        $crate::__register_accum! { hook = ($f), storage = ($($s)?), command = ($($c)?), ui = ($($u)?), editor = ($($e)?); $($($rest)*)? }
64    };
65    ( hook = ($($h:path)?), storage = ($($s:ty)?), command = ($($c:path)?), ui = ($($u:path)?), editor = ($($e:path)?); storage: $t:ty $(, $($rest:tt)*)? ) => {
66        $crate::__register_accum! { hook = ($($h)?), storage = ($t), command = ($($c)?), ui = ($($u)?), editor = ($($e)?); $($($rest)*)? }
67    };
68    ( hook = ($($h:path)?), storage = ($($s:ty)?), command = ($($c:path)?), ui = ($($u:path)?), editor = ($($e:path)?); command: $f:path $(, $($rest:tt)*)? ) => {
69        $crate::__register_accum! { hook = ($($h)?), storage = ($($s)?), command = ($f), ui = ($($u)?), editor = ($($e)?); $($($rest)*)? }
70    };
71    ( hook = ($($h:path)?), storage = ($($s:ty)?), command = ($($c:path)?), ui = ($($u:path)?), editor = ($($e:path)?); ui: $f:path $(, $($rest:tt)*)? ) => {
72        $crate::__register_accum! { hook = ($($h)?), storage = ($($s)?), command = ($($c)?), ui = ($f), editor = ($($e)?); $($($rest)*)? }
73    };
74    ( hook = ($($h:path)?), storage = ($($s:ty)?), command = ($($c:path)?), ui = ($($u:path)?), editor = ($($e:path)?); editor: $f:path $(, $($rest:tt)*)? ) => {
75        $crate::__register_accum! { hook = ($($h)?), storage = ($($s)?), command = ($($c)?), ui = ($($u)?), editor = ($f); $($($rest)*)? }
76    };
77    ( hook = ($($h:path)?), storage = ($($s:ty)?), command = ($($c:path)?), ui = ($($u:path)?), editor = ($($e:path)?); ) => {
78        $crate::__register_dispatch! { hook = ($($h)?), storage = ($($s)?), command = ($($c)?), ui = ($($u)?), editor = ($($e)?) }
79    };
80}
81
82#[doc(hidden)]
83#[macro_export]
84macro_rules! __register_dispatch {
85    ( hook = ($($hook:path)?), storage = ($($st:ty)?), command = ($($cmd:path)?), ui = ($($ui:path)?), editor = ($($editor:path)?) ) => {
86        // 业务路由:storage.* 方法族优先,其余按 method 匹配。native 下仅供测试。
87        #[allow(dead_code)]
88        fn __jasper_dispatch(
89            method: &str,
90            params: $crate::serde_json::Value,
91        ) -> ::std::result::Result<$crate::serde_json::Value, $crate::PluginError> {
92            $(
93                if let Some(r) = $crate::storage::dispatch_storage::<$st>(method, &params) {
94                    return r;
95                }
96            )?
97            match method {
98                "metadata" => Ok($crate::serde_json::json!({ "ok": true })),
99                $(
100                    "hook.before_save" => {
101                        let note: $crate::core::model::Note = $crate::serde_json::from_value(
102                            params.get("note").cloned().unwrap_or($crate::serde_json::Value::Null),
103                        )
104                        .map_err(|e| $crate::PluginError::invalid(format!("note 解析失败: {e}")))?;
105                        let hook: fn(
106                            $crate::core::model::Note,
107                        ) -> ::std::result::Result<$crate::core::model::Note, String> = $hook;
108                        let out = hook(note).map_err($crate::PluginError::internal)?;
109                        Ok($crate::serde_json::json!({ "note": out }))
110                    }
111                )?
112                $(
113                    "command" => {
114                        let id = params
115                            .get("id")
116                            .and_then(|v| v.as_str())
117                            .unwrap_or("")
118                            .to_string();
119                        let args = params.get("args").cloned().unwrap_or($crate::serde_json::Value::Null);
120                        let run: fn(
121                            &str,
122                            $crate::serde_json::Value,
123                        ) -> ::std::result::Result<$crate::serde_json::Value, $crate::PluginError> = $cmd;
124                        run(&id, args)
125                    }
126                )?
127                $(
128                    "ui" => {
129                        let view = params
130                            .get("view")
131                            .and_then(|v| v.as_str())
132                            .unwrap_or("")
133                            .to_string();
134                        let state = params.get("state").cloned().unwrap_or($crate::serde_json::Value::Null);
135                        let render: fn(
136                            &str,
137                            $crate::serde_json::Value,
138                        ) -> ::std::result::Result<$crate::serde_json::Value, $crate::PluginError> = $ui;
139                        render(&view, state)
140                    }
141                )?
142                $(
143                    "editor.transform" => {
144                        let phase = params
145                            .get("phase")
146                            .and_then(|v| v.as_str())
147                            .unwrap_or("")
148                            .to_string();
149                        let text = params
150                            .get("text")
151                            .and_then(|v| v.as_str())
152                            .unwrap_or("")
153                            .to_string();
154                        let transform: fn(
155                            &str,
156                            String,
157                        ) -> ::std::result::Result<String, $crate::PluginError> = $editor;
158                        let out = transform(&phase, text)?;
159                        Ok($crate::serde_json::json!({ "text": out }))
160                    }
161                )?
162                other => Err($crate::PluginError::unsupported(format!("未知方法: {other}"))),
163            }
164        }
165
166        #[cfg(target_arch = "wasm32")]
167        mod __jasper_plugin_abi {
168            #[no_mangle]
169            pub extern "C" fn plugin_alloc(size: u32) -> u32 {
170                $crate::rt::alloc(size as usize) as u32
171            }
172            #[no_mangle]
173            pub extern "C" fn plugin_free(ptr: u32, size: u32) {
174                $crate::rt::free(ptr as usize, size as usize)
175            }
176            #[no_mangle]
177            pub extern "C" fn plugin_dispatch(ptr: u32, len: u32) -> u64 {
178                $crate::rt::dispatch(ptr as usize, len as usize, super::__jasper_dispatch)
179            }
180        }
181    };
182}
183
184#[cfg(test)]
185mod tests {
186    use crate::core::model::{MarkupLanguage, Note};
187    use serde_json::json;
188
189    fn trim_hook(mut note: Note) -> Result<Note, String> {
190        note.body = note.body.lines().map(|l| l.trim_end()).collect::<Vec<_>>().join("\n");
191        Ok(note)
192    }
193
194    crate::register! { before_save: trim_hook }
195
196    fn sample_note() -> Note {
197        Note {
198            id: "a".repeat(32),
199            parent_id: String::new(),
200            title: "t".into(),
201            body: "x  \ny\t".into(),
202            created_time: 0,
203            updated_time: 0,
204            markup_language: MarkupLanguage::Markdown,
205            is_todo: false,
206            todo_completed: false,
207            is_conflict: false,
208            source_url: String::new(),
209            order: 0,
210        }
211    }
212
213    #[test]
214    fn dispatch_metadata_and_hook() {
215        let r = __jasper_dispatch("metadata", json!(null)).unwrap();
216        assert_eq!(r, json!({ "ok": true }));
217
218        let r = __jasper_dispatch("hook.before_save", json!({ "note": sample_note() })).unwrap();
219        assert_eq!(r["note"]["body"], "x\ny");
220
221        let e = __jasper_dispatch("nope", json!(null)).unwrap_err();
222        assert_eq!(e.code, "unsupported");
223    }
224}
225
226#[cfg(test)]
227mod ui_slot_tests {
228    use crate::PluginError;
229    use serde_json::{json, Value};
230
231    fn run(id: &str, args: Value) -> Result<Value, PluginError> {
232        Ok(json!({ "echoed": { "id": id, "args": args } }))
233    }
234
235    fn render(view: &str, state: Value) -> Result<Value, PluginError> {
236        Ok(json!({
237            "type": "markdown",
238            "props": { "source": format!("view={view}") },
239            "children": [ { "type": "button", "props": { "label": "go", "command": "x", "state": state } } ],
240        }))
241    }
242
243    crate::register! { command: run, ui: render }
244
245    #[test]
246    fn dispatch_routes_ui_and_command() {
247        let r = __jasper_dispatch("ui", json!({ "view": "main", "state": { "n": 1 } })).unwrap();
248        assert_eq!(r["type"], "markdown");
249        assert_eq!(r["props"]["source"], "view=main");
250        assert_eq!(r["children"][0]["props"]["state"]["n"], 1);
251
252        let r = __jasper_dispatch("command", json!({ "id": "c1", "args": { "a": 2 } })).unwrap();
253        assert_eq!(r["echoed"]["id"], "c1");
254
255        // 未挂 before_save → 该方法落到 unsupported
256        let e = __jasper_dispatch("hook.before_save", json!({ "note": null })).unwrap_err();
257        assert_eq!(e.code, "unsupported");
258    }
259}
260
261#[cfg(test)]
262mod editor_slot_tests {
263    use crate::PluginError;
264    use serde_json::json;
265
266    // 编辑期变换:把文本连同相位打成可观测的结果
267    fn transform(phase: &str, text: String) -> Result<String, PluginError> {
268        Ok(format!("[{phase}] {}", text.to_uppercase()))
269    }
270
271    crate::register! { editor: transform }
272
273    #[test]
274    fn dispatch_routes_editor_transform() {
275        let r = __jasper_dispatch("editor.transform", json!({ "phase": "input", "text": "hi there" })).unwrap();
276        assert_eq!(r["text"], "[input] HI THERE");
277
278        // 未挂 command → 落到 unsupported(证明只注册了 editor 槽)
279        let e = __jasper_dispatch("command", json!({ "id": "x" })).unwrap_err();
280        assert_eq!(e.code, "unsupported");
281    }
282}