weaveffi_model/plan.rs
1//! The **marshalling plan**: the language-neutral calling contracts every
2//! backend renders, stated once.
3//!
4//! The [`crate::model`] layer answers *which symbols exist and what their C
5//! signatures are*. This module answers the questions one level up, the ones
6//! the eleven generators used to answer independently (and inconsistently):
7//!
8//! * **Passing** ([`ArgPass`], [`RetPass`]): how each argument crosses into
9//! its ABI slots (by value, pinned string/bytes, serialized value buffer,
10//! borrowed object pointer, or callback context plus vtable) and what the
11//! wrapper does with the result (use, copy, decode, or adopt) and which
12//! release it owes (`{prefix}_free_bytes` or the interface's `_destroy`).
13//! * **Errors** ([`ErrorStrategy`]): when a call reports through `out_err`,
14//! is that a typed domain error the caller can catch, or a producer bug the
15//! wrapper must trap on?
16//! * **Iterators** ([`IteratorProtocol`]): the pull contract of `iter<T>`,
17//! including the requirement that wrappers stay **lazy** (one producer
18//! `next` per consumer step, never a hidden drain into a list).
19//! * **Async** ([`AsyncProtocol`]): the completion-callback contract,
20//! including the rule that results and errors are owned by the consumer
21//! and released through the runtime free symbols.
22//! * **Callback interfaces** ([`CallbackProtocol`]): the contract for the
23//! consumer-implemented vtable the producer calls back into.
24//!
25//! Every classification here derives from [`Ty::family`] and every symbol
26//! comes from the [`Model`](crate::model::Model), so a backend that renders
27//! these plans in its own syntax cannot drift from the others on semantics;
28//! only the spelling differs.
29
30use crate::abi::AbiParam;
31use crate::model::{
32 AsyncBinding, CallbackInterfaceBinding, CallbackMethodBinding, FnBinding, IteratorBinding,
33 ParamBinding,
34};
35use crate::ty::{Family, Ty};
36
37/// How a callable's `out_err` slot is interpreted by idiomatic wrappers.
38///
39/// Every synchronous C ABI entry point carries a trailing `out_err`, and every
40/// async completion callback carries an `err` slot, regardless of `throws`.
41/// What differs is the *meaning* of a non-zero code, and every backend must
42/// agree on it.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum ErrorStrategy {
45 /// The function declares `throws: true`: a non-zero code is a typed
46 /// domain error. The wrapper maps the code onto the module's error
47 /// domain (an exception subclass, a Swift `Error` enum case, a Go
48 /// `error` value, ...), decodes any payload fields from the error's
49 /// `payload_ptr`/`payload_len` buffer, and surfaces it through the
50 /// target's normal error channel so callers can catch and match on it.
51 /// A *negative* code (generic `-1`, panic `-2`, marshalling failure
52 /// `-3`, foreign callback failure `-4`, cancelled `-5`) surfaces through
53 /// the same channel as the target's runtime error type, distinct from
54 /// every domain error type, and `-5` as the language's cancellation error
55 /// where it has one.
56 Throws,
57 /// The function does not throw: the only way `out_err` reports failure
58 /// is a producer bug or a runtime trap (a caught panic, code `-2`; a
59 /// consumer callback that raised, code `-4`). The wrapper surfaces it
60 /// through the target's *programming-error* idiom (an unchecked exception
61 /// where the language has them, a Go `panic`, a Swift `fatalError`).
62 /// It must never be silently ignored, and it must never be dressed up as
63 /// a typed domain error.
64 Trap,
65}
66
67impl FnBinding {
68 /// The error strategy of this callable: [`ErrorStrategy::Throws`] when the
69 /// IDL declares `throws: true`, otherwise [`ErrorStrategy::Trap`].
70 pub fn error_strategy(&self) -> ErrorStrategy {
71 if self.throws {
72 ErrorStrategy::Throws
73 } else {
74 ErrorStrategy::Trap
75 }
76 }
77}
78
79/// How one parameter crosses the call boundary: the passing contract a
80/// wrapper renders when marshalling its native argument into ABI slots.
81///
82/// Exactly one variant applies to any parameter, and the borrowed
83/// [`AbiParam`] references point at the parameter's own precomputed slots,
84/// so a backend that dispatches on this enum cannot disagree with the C
85/// header about arity or slot order.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum ArgPass<'a> {
88 /// One slot passed by value: scalars, bools, and C-style enums.
89 Direct {
90 /// The single ABI slot.
91 slot: &'a AbiParam,
92 },
93 /// A borrowed `(ptr, len)` pair of UTF-8 bytes, not NUL-terminated. The
94 /// wrapper encodes the string and keeps the encoding alive for the
95 /// duration of the call; the producer copies what it needs.
96 String {
97 /// The `const uint8_t*` slot.
98 ptr: &'a AbiParam,
99 /// The `size_t` length slot.
100 len: &'a AbiParam,
101 },
102 /// A borrowed `(ptr, len)` byte pair. The wrapper pins its native byte
103 /// storage for the call; the producer copies what it needs.
104 Bytes {
105 /// The `uint8_t*` data slot.
106 ptr: &'a AbiParam,
107 /// The `size_t` length slot.
108 len: &'a AbiParam,
109 },
110 /// A buffered value (record, rich enum, optional, list, or map): the
111 /// wrapper serializes it into the value-buffer wire format
112 /// ([`Ty::wire`]), passes the encoding as a borrowed `(ptr, len)` pair,
113 /// and releases its own encoding after the call returns. Any object
114 /// token written into the encoding must be a freshly cloned reference
115 /// (the interface's
116 /// [`clone_symbol`](crate::model::InterfaceBinding::clone_symbol)).
117 Buffer {
118 /// The `const uint8_t*` data slot.
119 ptr: &'a AbiParam,
120 /// The `size_t` length slot.
121 len: &'a AbiParam,
122 },
123 /// A borrowed object pointer: the wrapper passes the wrapped object's
124 /// native handle and retains its own reference. When `nullable`, the IDL
125 /// type is `Interface?` and null means none.
126 Object {
127 /// The single object-pointer slot.
128 slot: &'a AbiParam,
129 /// `true` for `Interface?`: null is a legal "none" argument.
130 nullable: bool,
131 },
132 /// A callback interface: the wrapper registers its native implementation
133 /// in a handle table, passes the table key as `ctx` and the interface's
134 /// static vtable as `vtable`, and removes the entry when the producer
135 /// calls the vtable's `free` (see [`CallbackProtocol`]). When
136 /// `nullable`, the IDL type is `Cb?` and a null `vtable` means none.
137 Callback {
138 /// The `void*` context slot.
139 ctx: &'a AbiParam,
140 /// The `const {vtable}*` slot.
141 vtable: &'a AbiParam,
142 /// `true` for `Cb?`: a null vtable is a legal "none" argument.
143 nullable: bool,
144 },
145}
146
147impl ParamBinding {
148 /// The passing contract for this parameter.
149 ///
150 /// # Panics
151 ///
152 /// Panics if the parameter's precomputed ABI slots disagree with its
153 /// type's family, which would be a bug in the model construction, not a
154 /// user error.
155 pub fn arg_pass(&self) -> ArgPass<'_> {
156 let pair = || {
157 assert!(
158 self.abi.len() == 2,
159 "two-slot parameter '{}' must have exactly two ABI slots",
160 self.name
161 );
162 (&self.abi[0], &self.abi[1])
163 };
164 let single = || {
165 assert!(
166 self.abi.len() == 1,
167 "single-slot parameter '{}' must have exactly one ABI slot",
168 self.name
169 );
170 &self.abi[0]
171 };
172 match self.ty.family() {
173 Family::Direct => ArgPass::Direct { slot: single() },
174 Family::String => {
175 let (ptr, len) = pair();
176 ArgPass::String { ptr, len }
177 }
178 Family::Bytes => {
179 let (ptr, len) = pair();
180 ArgPass::Bytes { ptr, len }
181 }
182 Family::Buffer => {
183 let (ptr, len) = pair();
184 ArgPass::Buffer { ptr, len }
185 }
186 Family::Object { nullable } => ArgPass::Object {
187 slot: single(),
188 nullable,
189 },
190 Family::Callback { nullable } => {
191 let (ctx, vtable) = pair();
192 ArgPass::Callback {
193 ctx,
194 vtable,
195 nullable,
196 }
197 }
198 Family::Iterator => unreachable!("iterators are never parameters"),
199 }
200 }
201}
202
203/// How a value produced by the producer crosses back to the wrapper: the
204/// receiving contract, including the decode step and the release obligation.
205///
206/// Sync returns, async results, and iterator elements all use this.
207#[derive(Debug, Clone, PartialEq, Eq)]
208pub enum RetPass {
209 /// No return value.
210 Void,
211 /// By-value return (scalar, bool, C-style enum): use directly, nothing to
212 /// free.
213 Direct,
214 /// Owned `(const uint8_t*, out_len)` UTF-8: decode into the native string,
215 /// then release with
216 /// `{prefix}_free_bytes`.
217 String,
218 /// Owned `(const uint8_t*, out_len)` raw bytes: copy, then release with
219 /// `{prefix}_free_bytes`.
220 Bytes,
221 /// Owned `(const uint8_t*, out_len)` value buffer: decode via the wire
222 /// format ([`Ty::wire`]), adopting any object tokens it carries, then
223 /// release with `{prefix}_free_bytes`.
224 Buffer,
225 /// One strong object reference the wrapper adopts into its disposal
226 /// idiom, eventually released with the interface's `_destroy` symbol
227 /// ([`InterfaceBinding::destroy_symbol`], from
228 /// [`Model::interface`](crate::model::Model::interface)). When
229 /// `nullable`, the IDL type is `Interface?` and a null return means
230 /// none.
231 ///
232 /// [`InterfaceBinding::destroy_symbol`]: crate::model::InterfaceBinding::destroy_symbol
233 Object {
234 /// `true` for `Interface?`: a null return is a legal "none" result.
235 nullable: bool,
236 },
237}
238
239impl RetPass {
240 /// The receiving contract for a value of type `ty` produced by a
241 /// callable. `None` (a void return) is [`RetPass::Void`].
242 ///
243 /// # Panics
244 ///
245 /// Panics on an iterator return, whose contract is [`IteratorProtocol`],
246 /// not a value-passing plan (backends dispatch on
247 /// [`CallShape`](crate::model::CallShape) before consulting this), and on
248 /// a callback interface, which validation never admits as a return.
249 pub fn of(ty: Option<&Ty>) -> RetPass {
250 let Some(ty) = ty else {
251 return RetPass::Void;
252 };
253 match ty.family() {
254 Family::Direct => RetPass::Direct,
255 Family::String => RetPass::String,
256 Family::Bytes => RetPass::Bytes,
257 Family::Buffer => RetPass::Buffer,
258 Family::Object { nullable } => RetPass::Object { nullable },
259 Family::Callback { .. } => panic!("callback interfaces are never returned"),
260 Family::Iterator => panic!("iterator returns follow IteratorProtocol, not a RetPass"),
261 }
262 }
263}
264
265/// The `iter<T>` pull contract every backend renders.
266///
267/// The producer returns an opaque iterator handle; the consumer then calls
268/// `next` once per element and `destroy` exactly once when done. The binding
269/// contract has three clauses every wrapper must satisfy:
270///
271/// 1. **Laziness.** The wrapper exposes the target's native lazy iteration
272/// idiom (a Python iterator, a Ruby `Enumerator`, a Go `iter.Seq2`, a C#
273/// `IEnumerable`, a Dart `Iterable`, a JS iterable, a Swift `Sequence`, a
274/// Kotlin `Iterator`) and issues **one producer `next` call per consumer
275/// step**. Draining the producer into a hidden list defeats the point of
276/// `iter<T>` (constant-memory streaming) and is a contract violation.
277/// 2. **Element ownership.** Each `next` writes an element the consumer now
278/// owns; the wrapper receives it exactly as it would a return of the same
279/// type ([`elem`](Self::elem)): copy and free a string or bytes, decode
280/// and free a buffer, adopt an object.
281/// 3. **Handle lifecycle.** `destroy` is called exactly once: eagerly on
282/// exhaustion, and from the wrapper's disposal idiom (RAII destructor,
283/// finalizer, `close()`, generator cleanup) when iteration is abandoned
284/// early.
285///
286/// Each `next` call also carries `out_err` and follows the owning function's
287/// [`ErrorStrategy`].
288#[derive(Debug, Clone, PartialEq, Eq)]
289pub struct IteratorProtocol<'a> {
290 /// The lowered iterator surface: launcher, `next`, and destroy symbols.
291 pub binding: &'a IteratorBinding,
292 /// How each element written by `next` is received.
293 pub elem: RetPass,
294 /// How `out_err` reports from the launcher and each `next` call are
295 /// interpreted.
296 pub error: ErrorStrategy,
297}
298
299impl IteratorBinding {
300 /// Build the full pull contract for this iterator of `f`.
301 pub fn protocol<'a>(&'a self, f: &FnBinding) -> IteratorProtocol<'a> {
302 IteratorProtocol {
303 binding: self,
304 elem: RetPass::of(Some(&self.elem)),
305 error: f.error_strategy(),
306 }
307 }
308}
309
310/// The async completion contract every backend renders.
311///
312/// The launcher returns immediately; the producer later invokes the completion
313/// callback exactly once, from an arbitrary producer thread. The contract has
314/// three clauses:
315///
316/// 1. **Single completion.** The callback fires exactly once per launch; the
317/// wrapper resolves its native future idiom (a Python `asyncio` future, a
318/// JS `Promise`, a Swift continuation, a C# `TaskCompletionSource`, a Go
319/// channel) exactly once and then releases the registration.
320/// 2. **Owned results.** Everything passed to the callback is owned by the
321/// consumer. String results are released with `{prefix}_free_bytes`,
322/// byte and buffered-value results with `{prefix}_free_bytes`, and
323/// interface-object results transfer one strong reference (the wrapper
324/// adopts the pointer). This is what lets runtimes that defer callback
325/// bodies past the native return (Dart's `NativeCallable.listener`, for
326/// example) decode safely; a wrapper that processes results inline still
327/// copies or decodes first and then frees.
328/// 3. **Foreign-thread delivery.** The callback runs on a producer thread,
329/// so the wrapper must hop back to its native scheduler before touching
330/// consumer state (`call_soon_threadsafe`, a threadsafe function, a
331/// dispatched continuation) rather than resolving inline where the
332/// target's runtime forbids it.
333///
334/// The callback's `err` slot follows the owning function's [`ErrorStrategy`].
335/// A non-null error is heap-boxed and owned by the consumer: the wrapper
336/// copies the code, message, and payload, then releases the box with
337/// `{prefix}_error_free` exactly once.
338#[derive(Debug, Clone, PartialEq, Eq)]
339pub struct AsyncProtocol<'a> {
340 /// The lowered async surface: launcher and callback typedef.
341 pub binding: &'a AsyncBinding,
342 /// Whether the launcher carries a `cancel_token` slot before
343 /// `callback`/`context`.
344 pub cancellable: bool,
345 /// How the callback's result slots are received, including the release
346 /// owed (`{prefix}_free_bytes` or the destroy symbol an adopted object
347 /// owes).
348 pub result: RetPass,
349 /// How the callback's `err` slot is interpreted.
350 pub error: ErrorStrategy,
351}
352
353impl AsyncBinding {
354 /// Build the full completion contract for this async function `f`.
355 pub fn protocol<'a>(&'a self, f: &FnBinding) -> AsyncProtocol<'a> {
356 AsyncProtocol {
357 binding: self,
358 cancellable: f.cancellable,
359 result: RetPass::of(f.ret.as_ref()),
360 error: f.error_strategy(),
361 }
362 }
363}
364
365/// The callback-interface contract every backend renders.
366///
367/// A callback interface is the consumer's side of the boundary: the consumer
368/// supplies an implementation, the producer calls it. The contract has five
369/// clauses:
370///
371/// 1. **One static vtable per interface.** The wrapper emits exactly one
372/// process-wide vtable value for the interface. It starts with the fixed
373/// header, `size` (`sizeof` the vtable as the wrapper compiled it),
374/// `flags` (`0`), and `free`, followed by one trampoline per method from
375/// the C signature ([`CallbackMethodBinding::abi_params`]) into the native
376/// implementation. The producer rejects a vtable smaller than the one it
377/// was built with, so a stale binding fails with `-3` instead of calling
378/// through a missing slot.
379/// 2. **Context is a handle-table key.** The wrapper stores the native
380/// implementation in a table keyed by an integer or pointer it passes as
381/// `ctx`, so the implementation stays alive as long as the producer holds
382/// the callback and garbage collectors never see a raw pointer. `free`
383/// may run on any producer thread.
384/// 3. **Arguments are received like returns.** Strings, bytes, and buffers
385/// arriving in a trampoline are borrowed for the call: the wrapper copies
386/// or decodes them before returning and frees nothing. Object arguments
387/// transfer one strong reference the wrapper adopts.
388/// 4. **Returns transfer to the producer** ([`method_returns`](Self::method_returns)).
389/// A direct value is the C return. An object is returned as one strong
390/// reference (a fresh `_clone` of the wrapper's handle). A string, bytes,
391/// or buffer is written to the trailing `out_ptr`/`out_len` slots as a run
392/// the wrapper allocates with `{prefix}_alloc`; the producer adopts it.
393/// 5. **Failures go through `out_err`.** When the native implementation
394/// raises, the trampoline calls `{prefix}_error_set(out_err, code,
395/// message)` and returns a zero value; it must never let an exception
396/// unwind through the C frame. A method whose
397/// [`method_errors`](Self::method_errors) entry is
398/// [`ErrorStrategy::Throws`] reports a declared domain error with its
399/// positive code and its payload fields (`{prefix}_error_set_payload`);
400/// every other failure uses `-4`, and the producer treats any code it
401/// can't attribute to the method's domain as `-4`.
402///
403/// Trampolines may be invoked from any producer thread; the wrapper is
404/// responsible for whatever thread affinity its runtime demands (a GIL
405/// acquisition, a JNI attach, a threadsafe-function hop).
406///
407/// [`CallbackMethodBinding::abi_params`]: crate::model::CallbackMethodBinding::abi_params
408#[derive(Debug, Clone, PartialEq, Eq)]
409pub struct CallbackProtocol<'a> {
410 /// The lowered callback interface: vtable tag and methods.
411 pub binding: &'a CallbackInterfaceBinding,
412 /// How each method's parameters are received inside a trampoline, in
413 /// method order then parameter order.
414 pub method_args: Vec<Vec<RetPass>>,
415 /// How each method's return value crosses back to the producer, in
416 /// method order. [`RetPass::String`], [`RetPass::Bytes`], and
417 /// [`RetPass::Buffer`] are written to the `out_ptr`/`out_len` slots as a
418 /// `{prefix}_alloc` run.
419 pub method_returns: Vec<RetPass>,
420 /// How each method may report failure, in method order.
421 pub method_errors: Vec<ErrorStrategy>,
422}
423
424impl CallbackMethodBinding {
425 /// The error strategy of this method: [`ErrorStrategy::Throws`] when it
426 /// declares `throws: true`, otherwise [`ErrorStrategy::Trap`] (any
427 /// failure reaches the producer as `-4`).
428 pub fn error_strategy(&self) -> ErrorStrategy {
429 if self.throws {
430 ErrorStrategy::Throws
431 } else {
432 ErrorStrategy::Trap
433 }
434 }
435}
436
437impl CallbackInterfaceBinding {
438 /// Build the full contract for this callback interface.
439 pub fn protocol(&self) -> CallbackProtocol<'_> {
440 CallbackProtocol {
441 binding: self,
442 method_args: self
443 .methods
444 .iter()
445 .map(|m| m.params.iter().map(|p| RetPass::of(Some(&p.ty))).collect())
446 .collect(),
447 method_returns: self
448 .methods
449 .iter()
450 .map(|m| RetPass::of(m.ret.as_ref()))
451 .collect(),
452 method_errors: self
453 .methods
454 .iter()
455 .map(CallbackMethodBinding::error_strategy)
456 .collect(),
457 }
458 }
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464 use crate::ir::{Api, Function, Param, TypeRef};
465 use crate::model::Model;
466 use crate::pkg::Identity;
467 use crate::ty::Prim;
468
469 fn param(name: &str, ty: &str) -> Param {
470 Param {
471 name: name.into(),
472 ty: crate::ir::parse_type_ref(ty).unwrap(),
473 doc: None,
474 }
475 }
476
477 /// A model with interface `Store` and callback interface `Listener` in
478 /// `kv`, a record `Contact`, and one function `f` taking `params`.
479 fn model(params: Vec<Param>) -> Model {
480 let yaml = r#"
481version: "0.11.0"
482modules:
483 - name: kv
484 structs: [{ name: Contact, fields: [{ name: id, type: i64 }] }]
485 interfaces: [{ name: Store, methods: [{ name: get }] }]
486 callback_interfaces: [{ name: Listener, methods: [{ name: on }] }]
487"#;
488 let mut api: Api = serde_yaml::from_str(yaml).unwrap();
489 api.modules[0].functions.push(Function {
490 name: "f".into(),
491 params,
492 returns: Some(TypeRef::Prim(Prim::I32)),
493 doc: None,
494 throws: false,
495 r#async: false,
496 cancellable: false,
497 deprecated: None,
498 });
499 Model::assume_valid(&api, Identity::named("weaveffi"))
500 }
501
502 #[test]
503 fn arg_pass_classifies_every_family() {
504 let m = model(vec![
505 param("x", "i32"),
506 param("s", "string"),
507 param("data", "bytes"),
508 param("c", "Contact"),
509 param("o", "i32?"),
510 param("store", "Store"),
511 param("maybe", "Store?"),
512 param("l", "Listener"),
513 param("ml", "Listener?"),
514 ]);
515 let p = &m.modules[0].functions[0].params;
516 assert!(matches!(p[0].arg_pass(), ArgPass::Direct { slot } if slot.name == "x"));
517 assert!(matches!(p[1].arg_pass(), ArgPass::String { ptr, .. } if ptr.name == "s_ptr"));
518 assert!(matches!(
519 p[2].arg_pass(),
520 ArgPass::Bytes { ptr, len } if ptr.name == "data_ptr" && len.name == "data_len"
521 ));
522 assert!(matches!(
523 p[3].arg_pass(),
524 ArgPass::Buffer { ptr, len } if ptr.name == "c_ptr" && len.name == "c_len"
525 ));
526 assert!(matches!(p[4].arg_pass(), ArgPass::Buffer { .. }));
527 assert!(matches!(
528 p[5].arg_pass(),
529 ArgPass::Object {
530 nullable: false,
531 ..
532 }
533 ));
534 assert!(matches!(
535 p[6].arg_pass(),
536 ArgPass::Object { nullable: true, .. }
537 ));
538 assert!(matches!(
539 p[7].arg_pass(),
540 ArgPass::Callback { ctx, vtable, nullable: false }
541 if ctx.name == "l_ctx" && vtable.name == "l_vtable"
542 ));
543 assert!(matches!(
544 p[8].arg_pass(),
545 ArgPass::Callback { nullable: true, .. }
546 ));
547 }
548
549 #[test]
550 fn ret_pass_distinguishes_copy_decode_and_adopt() {
551 assert_eq!(RetPass::of(None), RetPass::Void);
552 assert_eq!(RetPass::of(Some(&Ty::Prim(Prim::I64))), RetPass::Direct);
553 assert_eq!(RetPass::of(Some(&Ty::Prim(Prim::String))), RetPass::String);
554 assert_eq!(RetPass::of(Some(&Ty::Prim(Prim::Bytes))), RetPass::Bytes);
555 for ty in [
556 Ty::Record("Contact".into()),
557 Ty::RichEnum("Shape".into()),
558 Ty::List(Box::new(Ty::Prim(Prim::String))),
559 Ty::List(Box::new(Ty::Interface("Store".into()))),
560 Ty::Optional(Box::new(Ty::Prim(Prim::I64))),
561 ] {
562 assert_eq!(RetPass::of(Some(&ty)), RetPass::Buffer, "{ty}");
563 }
564 assert_eq!(
565 RetPass::of(Some(&Ty::Interface("Store".into()))),
566 RetPass::Object { nullable: false }
567 );
568 assert_eq!(
569 RetPass::of(Some(&Ty::Optional(Box::new(Ty::Interface("Store".into()))))),
570 RetPass::Object { nullable: true }
571 );
572 }
573}