Skip to main content

fidius_wit/
lib.rs

1// Copyright 2026 Colliery, Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! WIT generation for the Fidius WASM backend (FIDIUS-I-0023).
16//!
17//! Maps the Rust types in a `#[plugin_interface]` to WIT, per
18//! `docs/explanation/wasm-component-abi.md`. Primitives, `String`, `Vec<T>`,
19//! `Option<T>`, and `Result<T, PluginError>` map directly; user types that
20//! carry `#[derive(WitType)]` map to WIT `record`s (structs) and `variant`s
21//! (enums), referenced by their kebab-case name.
22//!
23//! This is a plain library (not a proc-macro), so `fidius-macro`, the `build.rs`
24//! helper, and the `fidius wit` CLI can all share one implementation.
25
26use std::collections::BTreeSet;
27
28use syn::{Fields, GenericArgument, ItemEnum, ItemStruct, PathArguments, Type};
29
30mod generate;
31pub use generate::{contains_user_type, conv_expr, generate, generate_from_path, Generated};
32
33/// Convert a Rust identifier (CamelCase or snake_case) to kebab-case, the WIT
34/// naming convention. `BytePipe` → `byte-pipe`, `echo_bytes` → `echo-bytes`.
35pub fn to_kebab_case(s: &str) -> String {
36    let mut out = String::new();
37    for (i, ch) in s.chars().enumerate() {
38        if ch == '_' {
39            out.push('-');
40        } else if ch.is_uppercase() {
41            if i != 0 {
42                out.push('-');
43            }
44            out.extend(ch.to_lowercase());
45        } else {
46            out.push(ch);
47        }
48    }
49    out
50}
51
52/// Extract the `T` from `Result<T, _>`, if `ty` is a `Result`.
53pub fn result_ok_type(ty: &Type) -> Option<&Type> {
54    if let Type::Path(p) = ty {
55        if let Some(seg) = p.path.segments.last() {
56            if seg.ident == "Result" {
57                return first_generic(seg);
58            }
59        }
60    }
61    None
62}
63
64/// One method projected to WIT (already-mapped strings).
65pub struct WitMethod {
66    /// kebab-case export name.
67    pub name: String,
68    /// `(param-name, wit-type)` pairs, in order.
69    pub params: Vec<(String, String)>,
70    /// WIT return type, or `None` for no return.
71    pub ret: Option<String>,
72    /// For a **server-streaming** method (`-> fidius::Stream<T>`): the WIT item
73    /// type `T` (FIDIUS-I-0026). When `Some`, the method renders as an exported
74    /// `resource <name>-stream { next: func() -> result<option<T>, plugin-error>; }`
75    /// and the func returns that resource (`-> <name>-stream`). `None` for a
76    /// normal func.
77    pub stream_item: Option<String>,
78}
79
80/// If `ty` is `fidius::Stream<T>` (final path segment `Stream`, exactly one type
81/// argument), return `T`. The server-streaming marker (FIDIUS-I-0026, D4). Public
82/// so the macro's WASM adapter shares the same detection.
83pub fn stream_item_type(ty: &Type) -> Option<&Type> {
84    if let Type::Path(p) = ty {
85        if let Some(seg) = p.path.segments.last() {
86            if seg.ident == "Stream" {
87                return first_generic(seg);
88            }
89        }
90    }
91    None
92}
93
94/// Map a Rust argument/return type to its WIT spelling, where `known` holds the
95/// Rust identifiers of `#[derive(WitType)]` user types (mapped to their
96/// kebab-case record/variant name). Returns `Err(msg)` for unsupported types.
97pub fn wit_type_with(ty: &Type, known: &BTreeSet<String>) -> Result<String, String> {
98    match ty {
99        // Peel references: `&str` → string, `&[u8]` → list<u8>, `&T` → wit(T).
100        Type::Reference(r) => {
101            if let Type::Path(p) = r.elem.as_ref() {
102                if path_is(p, "str") {
103                    return Ok("string".to_string());
104                }
105            }
106            if let Type::Slice(s) = r.elem.as_ref() {
107                let inner = wit_type_with(&s.elem, known)?;
108                return Ok(format!("list<{inner}>"));
109            }
110            wit_type_with(&r.elem, known)
111        }
112        Type::Path(p) => {
113            let seg = p
114                .path
115                .segments
116                .last()
117                .ok_or_else(|| "empty type path".to_string())?;
118            let ident = seg.ident.to_string();
119            match ident.as_str() {
120                "bool" => Ok("bool".into()),
121                "i8" => Ok("s8".into()),
122                "i16" => Ok("s16".into()),
123                "i32" => Ok("s32".into()),
124                "i64" => Ok("s64".into()),
125                "u8" => Ok("u8".into()),
126                "u16" => Ok("u16".into()),
127                "u32" => Ok("u32".into()),
128                "u64" => Ok("u64".into()),
129                "f32" => Ok("f32".into()),
130                "f64" => Ok("f64".into()),
131                "char" => Ok("char".into()),
132                "String" => Ok("string".into()),
133                "Vec" => {
134                    let inner = single_generic(seg, "Vec")?;
135                    Ok(format!("list<{}>", wit_type_with(inner, known)?))
136                }
137                "Option" => {
138                    let inner = single_generic(seg, "Option")?;
139                    Ok(format!("option<{}>", wit_type_with(inner, known)?))
140                }
141                // Maps have no native WIT type; project to a list of key/value
142                // pairs — `list<tuple<k, v>>` — which round-trips any key type
143                // (not just strings). Insertion order is not preserved.
144                "HashMap" | "BTreeMap" => {
145                    let (k, v) = two_generics(seg, &ident)?;
146                    Ok(format!(
147                        "list<tuple<{}, {}>>",
148                        wit_type_with(k, known)?,
149                        wit_type_with(v, known)?
150                    ))
151                }
152                // A user type with `#[derive(WitType)]` → its kebab record/variant name.
153                other if known.contains(other) => Ok(to_kebab_case(other)),
154                other => Err(format!(
155                    "type `{other}` is not supported in a WASM fidius interface \
156                     (supported: bool, i8..i64, u8..u64, f32/f64, char, String, Vec<T>, \
157                     Option<T>, HashMap/BTreeMap<K, V>, tuples, Result<T, PluginError>, \
158                     and #[derive(WitType)] structs/enums)"
159                )),
160            }
161        }
162        Type::Tuple(t) if t.elems.is_empty() => {
163            Err("unit `()` is not a valid argument type".to_string())
164        }
165        // Non-empty tuple `(A, B, …)` → WIT `tuple<a, b, …>`.
166        Type::Tuple(t) => {
167            let elems = t
168                .elems
169                .iter()
170                .map(|e| wit_type_with(e, known))
171                .collect::<Result<Vec<_>, _>>()?;
172            Ok(format!("tuple<{}>", elems.join(", ")))
173        }
174        _ => Err("unsupported type in a WASM fidius interface".to_string()),
175    }
176}
177
178/// Primitive/std-only mapping (no user types) — the form `fidius-macro` uses for
179/// the descriptor/method tables.
180pub fn rust_type_to_wit(ty: &Type) -> Result<String, String> {
181    wit_type_with(ty, &BTreeSet::new())
182}
183
184/// Map a method's return type to an optional WIT return, with user types in
185/// `known`. `Result<T, PluginError>` → `result<T, plugin-error>` (or
186/// `result<_, plugin-error>` for `T = ()`); `()`/none → no return.
187pub fn return_to_wit_with(
188    ret: Option<&Type>,
189    known: &BTreeSet<String>,
190) -> Result<Option<String>, String> {
191    let Some(ty) = ret else { return Ok(None) };
192    if is_unit(ty) {
193        return Ok(None);
194    }
195    if let Type::Path(p) = ty {
196        if let Some(seg) = p.path.segments.last() {
197            if seg.ident == "Result" {
198                let ok = first_generic(seg).ok_or_else(|| "Result needs type args".to_string())?;
199                let ok_wit = if is_unit(ok) {
200                    "_".to_string()
201                } else {
202                    wit_type_with(ok, known)?
203                };
204                return Ok(Some(format!("result<{ok_wit}, plugin-error>")));
205            }
206        }
207    }
208    Ok(Some(wit_type_with(ty, known)?))
209}
210
211/// Primitive/std-only return mapping (no user types).
212pub fn return_to_wit(ret: Option<&Type>) -> Result<Option<String>, String> {
213    return_to_wit_with(ret, &BTreeSet::new())
214}
215
216/// Render a `record <name> { ... }` WIT block from a Rust struct (named fields
217/// only). Field types are mapped with `known` so they may reference other user
218/// types.
219pub fn struct_to_record(item: &ItemStruct, known: &BTreeSet<String>) -> Result<String, String> {
220    let name = to_kebab_case(&item.ident.to_string());
221    let Fields::Named(fields) = &item.fields else {
222        return Err(format!(
223            "WitType struct `{}` must have named fields (tuple/unit structs are not supported)",
224            item.ident
225        ));
226    };
227    let mut out = format!("    record {name} {{\n");
228    for f in &fields.named {
229        let fname = to_kebab_case(&f.ident.as_ref().unwrap().to_string());
230        let fty = wit_type_with(&f.ty, known)
231            .map_err(|e| format!("field `{}` of `{}`: {e}", fname, item.ident))?;
232        out.push_str(&format!("        {fname}: {fty},\n"));
233    }
234    out.push_str("    }\n");
235    Ok(out)
236}
237
238/// Render a Rust enum to WIT: a `variant <name> { ... }` plus any **synthetic
239/// records** for struct-shaped cases. Returns `(synthetic_records, variant)`.
240///
241/// Case shapes: unit → `case`; single tuple field → `case(type)`; named fields
242/// (`Case { .. }`) → a synthesized `record <enum>-<case>` and `case(<enum>-<case>)`.
243/// A multi-field *tuple* case is rejected: WIT cases take one payload, and a
244/// multi-field tuple serializes as a sequence (not a record) via serde, so it
245/// can't round-trip — use a struct variant `Case { .. }` instead.
246pub fn enum_to_wit(
247    item: &ItemEnum,
248    known: &BTreeSet<String>,
249) -> Result<(Vec<String>, String), String> {
250    let ename = item.ident.to_string();
251    let name = to_kebab_case(&ename);
252    let mut records = Vec::new();
253    let mut out = format!("    variant {name} {{\n");
254    for v in &item.variants {
255        let case = to_kebab_case(&v.ident.to_string());
256        match &v.fields {
257            Fields::Unit => out.push_str(&format!("        {case},\n")),
258            Fields::Unnamed(u) if u.unnamed.len() == 1 => {
259                let payload = wit_type_with(&u.unnamed[0].ty, known)
260                    .map_err(|e| format!("variant `{ename}::{}`: {e}", v.ident))?;
261                out.push_str(&format!("        {case}({payload}),\n"));
262            }
263            Fields::Named(f) => {
264                // Synthesize `record <enum>-<case> { .. }` for the case payload.
265                let rec_name = format!("{name}-{case}");
266                let mut rec = format!("    record {rec_name} {{\n");
267                for fl in &f.named {
268                    let fname = to_kebab_case(&fl.ident.as_ref().unwrap().to_string());
269                    let fty = wit_type_with(&fl.ty, known)
270                        .map_err(|e| format!("field `{fname}` of `{ename}::{}`: {e}", v.ident))?;
271                    rec.push_str(&format!("        {fname}: {fty},\n"));
272                }
273                rec.push_str("    }\n");
274                records.push(rec);
275                out.push_str(&format!("        {case}({rec_name}),\n"));
276            }
277            Fields::Unnamed(_) => {
278                return Err(format!(
279                    "WitType enum `{ename}` variant `{}` has multiple tuple fields; \
280                     a WIT variant case takes one payload — use a struct variant \
281                     `{} {{ .. }}` (or a single field)",
282                    v.ident, v.ident
283                ));
284            }
285        }
286    }
287    out.push_str("    }\n");
288    Ok((records, out))
289}
290
291/// Render a complete `.wit` document: package + interface (the `plugin-error`
292/// record, any user `type_defs` (records/variants), the funcs, and the
293/// `fidius-interface-hash` carrier) + the `<iface>-plugin` world. `type_defs`
294/// are pre-rendered (see [`struct_to_record`] / [`enum_to_variant`]).
295pub fn render_wit_full(iface_kebab: &str, type_defs: &[String], methods: &[WitMethod]) -> String {
296    let mut s = String::new();
297    s.push_str(&format!("package fidius:{iface_kebab}@0.1.0;\n\n"));
298    s.push_str(&format!("interface {iface_kebab} {{\n"));
299    s.push_str("    record plugin-error {\n");
300    s.push_str("        code: string,\n");
301    s.push_str("        message: string,\n");
302    s.push_str("        details: option<string>,\n");
303    s.push_str("    }\n");
304    for def in type_defs {
305        s.push_str(def);
306    }
307    // Resource declarations for streaming methods, before the funcs that return
308    // them (FIDIUS-I-0026): `resource <m>-stream { next: ... }`.
309    for m in methods {
310        if let Some(item) = &m.stream_item {
311            s.push_str(&format!("    resource {}-stream {{\n", m.name));
312            s.push_str(&format!(
313                "        next: func() -> result<option<{item}>, plugin-error>;\n"
314            ));
315            s.push_str("    }\n");
316        }
317    }
318    for m in methods {
319        let params = m
320            .params
321            .iter()
322            .map(|(n, t)| format!("{n}: {t}"))
323            .collect::<Vec<_>>()
324            .join(", ");
325        if m.stream_item.is_some() {
326            // Streaming: the func returns the owned stream resource.
327            s.push_str(&format!(
328                "    {}: func({params}) -> {}-stream;\n",
329                m.name, m.name
330            ));
331        } else {
332            match &m.ret {
333                Some(r) => s.push_str(&format!("    {}: func({params}) -> {r};\n", m.name)),
334                None => s.push_str(&format!("    {}: func({params});\n", m.name)),
335            }
336        }
337    }
338    s.push_str("    fidius-interface-hash: func() -> u64;\n");
339    // FIDIUS-A-0006 / CI.3: configured-instance constructor. The host calls it
340    // once to bind config (empty bytes for a zero-config plugin); the guest sets
341    // its instance. A carrier like fidius-interface-hash — not part of the
342    // interface hash (which derives from the trait method signatures).
343    s.push_str("    fidius-configure: func(config: list<u8>);\n");
344    s.push_str("}\n\n");
345    s.push_str(&format!(
346        "world {iface_kebab}-plugin {{\n    export {iface_kebab};\n}}\n"
347    ));
348    s
349}
350
351/// Convenience: render a WIT document with no user type defs (the primitives-only
352/// form `fidius-macro` emits inline today).
353pub fn render_wit(iface_kebab: &str, methods: &[WitMethod]) -> String {
354    render_wit_full(iface_kebab, &[], methods)
355}
356
357// ── helpers ─────────────────────────────────────────────────────────────────
358
359fn is_unit(ty: &Type) -> bool {
360    matches!(ty, Type::Tuple(t) if t.elems.is_empty())
361}
362
363fn path_is(p: &syn::TypePath, name: &str) -> bool {
364    p.path
365        .segments
366        .last()
367        .map(|s| s.ident == name)
368        .unwrap_or(false)
369}
370
371fn single_generic<'a>(seg: &'a syn::PathSegment, what: &str) -> Result<&'a Type, String> {
372    first_generic(seg).ok_or_else(|| format!("`{what}` needs one type argument"))
373}
374
375fn first_generic(seg: &syn::PathSegment) -> Option<&Type> {
376    if let PathArguments::AngleBracketed(ab) = &seg.arguments {
377        for a in &ab.args {
378            if let GenericArgument::Type(t) = a {
379                return Some(t);
380            }
381        }
382    }
383    None
384}
385
386/// Extract the first two type arguments (e.g. the key and value of a `Map<K, V>`).
387fn two_generics<'a>(seg: &'a syn::PathSegment, what: &str) -> Result<(&'a Type, &'a Type), String> {
388    if let PathArguments::AngleBracketed(ab) = &seg.arguments {
389        let types: Vec<&Type> = ab
390            .args
391            .iter()
392            .filter_map(|a| match a {
393                GenericArgument::Type(t) => Some(t),
394                _ => None,
395            })
396            .collect();
397        if types.len() >= 2 {
398            return Ok((types[0], types[1]));
399        }
400    }
401    Err(format!("`{what}` needs two type arguments (key, value)"))
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    fn known(names: &[&str]) -> BTreeSet<String> {
409        names.iter().map(|s| s.to_string()).collect()
410    }
411    fn wit(s: &str) -> String {
412        rust_type_to_wit(&syn::parse_str::<Type>(s).unwrap()).unwrap()
413    }
414
415    #[test]
416    fn primitives_strings_containers() {
417        assert_eq!(wit("i64"), "s64");
418        assert_eq!(wit("u32"), "u32");
419        assert_eq!(wit("String"), "string");
420        assert_eq!(wit("& str"), "string");
421        assert_eq!(wit("Vec<u8>"), "list<u8>");
422        assert_eq!(wit("Option<i32>"), "option<s32>");
423        assert_eq!(wit("&[u8]"), "list<u8>");
424    }
425
426    #[test]
427    fn maps_tuples_and_nesting() {
428        // Maps → list<tuple<k, v>> (any key type, not just strings).
429        assert_eq!(wit("HashMap<String, i64>"), "list<tuple<string, s64>>");
430        assert_eq!(wit("BTreeMap<u32, String>"), "list<tuple<u32, string>>");
431        // Tuples → tuple<...>.
432        assert_eq!(wit("(i32, String)"), "tuple<s32, string>");
433        assert_eq!(wit("(u8, u8, bool)"), "tuple<u8, u8, bool>");
434        // Nesting composes recursively.
435        assert_eq!(wit("Vec<Option<i32>>"), "list<option<s32>>");
436        assert_eq!(wit("Option<Vec<u8>>"), "option<list<u8>>");
437        assert_eq!(
438            wit("HashMap<String, Vec<i64>>"),
439            "list<tuple<string, list<s64>>>"
440        );
441        // Map/tuple carrying a user record (kebab) via the known set.
442        let k = known(&["Row"]);
443        let wk = |s: &str| wit_type_with(&syn::parse_str::<Type>(s).unwrap(), &k).unwrap();
444        assert_eq!(wk("Vec<Row>"), "list<row>");
445        assert_eq!(wk("HashMap<String, Row>"), "list<tuple<string, row>>");
446        assert_eq!(wk("(Row, i32)"), "tuple<row, s32>");
447    }
448
449    #[test]
450    fn returns() {
451        let ret = |s: &str| return_to_wit(Some(&syn::parse_str::<Type>(s).unwrap())).unwrap();
452        assert_eq!(ret("()"), None);
453        assert_eq!(
454            ret("Result<i64, PluginError>"),
455            Some("result<s64, plugin-error>".into())
456        );
457        assert_eq!(
458            ret("Result<(), PluginError>"),
459            Some("result<_, plugin-error>".into())
460        );
461    }
462
463    #[test]
464    fn user_types_need_the_known_set() {
465        let ty: Type = syn::parse_str("Point").unwrap();
466        assert!(rust_type_to_wit(&ty).is_err()); // unknown without the set
467        let k = known(&["Point"]);
468        assert_eq!(wit_type_with(&ty, &k).unwrap(), "point");
469        // nested in containers
470        let v: Type = syn::parse_str("Vec<Point>").unwrap();
471        assert_eq!(wit_type_with(&v, &k).unwrap(), "list<point>");
472        let o: Type = syn::parse_str("Option<BytePipe>").unwrap();
473        assert_eq!(
474            wit_type_with(&o, &known(&["BytePipe"])).unwrap(),
475            "option<byte-pipe>"
476        );
477    }
478
479    #[test]
480    fn struct_renders_to_record() {
481        let item: ItemStruct = syn::parse_str("struct Point { x: i32, y_pos: u64 }").unwrap();
482        let rec = struct_to_record(&item, &BTreeSet::new()).unwrap();
483        assert!(rec.contains("record point {"));
484        assert!(rec.contains("x: s32,"));
485        assert!(rec.contains("y-pos: u64,"));
486    }
487
488    #[test]
489    fn struct_with_nested_user_type() {
490        let item: ItemStruct = syn::parse_str("struct Line { from: Point, to: Point }").unwrap();
491        let rec = struct_to_record(&item, &known(&["Point"])).unwrap();
492        assert!(rec.contains("from: point,"));
493        assert!(rec.contains("to: point,"));
494    }
495
496    #[test]
497    fn enum_renders_to_variant() {
498        let item: ItemEnum =
499            syn::parse_str("enum Shape { Circle(u32), Rect(Point), Dot }").unwrap();
500        let (records, var) = enum_to_wit(&item, &known(&["Point"])).unwrap();
501        assert!(records.is_empty());
502        assert!(var.contains("variant shape {"));
503        assert!(var.contains("circle(u32),"));
504        assert!(var.contains("rect(point),"));
505        assert!(var.contains("dot,"));
506    }
507
508    #[test]
509    fn struct_variant_synthesizes_a_record() {
510        let item: ItemEnum = syn::parse_str("enum Shape { Rect { w: u32, h: u32 }, Dot }").unwrap();
511        let (records, var) = enum_to_wit(&item, &BTreeSet::new()).unwrap();
512        assert_eq!(records.len(), 1);
513        assert!(records[0].contains("record shape-rect {"));
514        assert!(records[0].contains("w: u32,"));
515        assert!(records[0].contains("h: u32,"));
516        assert!(var.contains("rect(shape-rect),"));
517        assert!(var.contains("dot,"));
518    }
519
520    #[test]
521    fn multifield_tuple_variant_is_rejected() {
522        let item: ItemEnum = syn::parse_str("enum E { Pair(u32, u32) }").unwrap();
523        assert!(enum_to_wit(&item, &BTreeSet::new()).is_err());
524    }
525
526    #[test]
527    fn full_document_places_type_defs_before_funcs() {
528        let recs = vec![struct_to_record(
529            &syn::parse_str("struct Point { x: i32, y: i32 }").unwrap(),
530            &BTreeSet::new(),
531        )
532        .unwrap()];
533        let methods = vec![WitMethod {
534            name: "midpoint".into(),
535            params: vec![("a".into(), "point".into()), ("b".into(), "point".into())],
536            ret: Some("point".into()),
537            stream_item: None,
538        }];
539        let doc = render_wit_full("geo", &recs, &methods);
540        assert!(doc.contains("package fidius:geo@0.1.0;"));
541        let rec_at = doc.find("record point {").unwrap();
542        let fn_at = doc.find("midpoint: func").unwrap();
543        assert!(
544            rec_at < fn_at,
545            "records must precede funcs in the interface"
546        );
547        assert!(doc.contains("midpoint: func(a: point, b: point) -> point;"));
548        assert!(doc.contains("fidius-interface-hash: func() -> u64;"));
549        assert!(doc.contains("world geo-plugin {"));
550    }
551
552    #[test]
553    fn streaming_method_renders_a_resource() {
554        let methods = vec![WitMethod {
555            name: "tick".into(),
556            params: vec![("count".into(), "u32".into())],
557            ret: None,
558            stream_item: Some("u64".into()),
559        }];
560        let doc = render_wit("ticker", &methods);
561        // Resource declared, with the poll method...
562        assert!(doc.contains("resource tick-stream {"));
563        assert!(doc.contains("next: func() -> result<option<u64>, plugin-error>;"));
564        // ...and the func returns the owned resource.
565        assert!(doc.contains("tick: func(count: u32) -> tick-stream;"));
566        // Resource precedes the func that returns it.
567        assert!(doc.find("resource tick-stream").unwrap() < doc.find("tick: func").unwrap());
568    }
569
570    #[test]
571    fn stream_item_type_detects_marker() {
572        let ty: Type = syn::parse_str("fidius::Stream<u64>").unwrap();
573        let item = stream_item_type(&ty).unwrap();
574        assert_eq!(rust_type_to_wit(item).unwrap(), "u64");
575        let bare: Type = syn::parse_str("Stream<String>").unwrap();
576        assert!(stream_item_type(&bare).is_some());
577        let not: Type = syn::parse_str("Vec<u64>").unwrap();
578        assert!(stream_item_type(&not).is_none());
579    }
580}