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}
73
74/// Map a Rust argument/return type to its WIT spelling, where `known` holds the
75/// Rust identifiers of `#[derive(WitType)]` user types (mapped to their
76/// kebab-case record/variant name). Returns `Err(msg)` for unsupported types.
77pub fn wit_type_with(ty: &Type, known: &BTreeSet<String>) -> Result<String, String> {
78    match ty {
79        // Peel references: `&str` → string, `&[u8]` → list<u8>, `&T` → wit(T).
80        Type::Reference(r) => {
81            if let Type::Path(p) = r.elem.as_ref() {
82                if path_is(p, "str") {
83                    return Ok("string".to_string());
84                }
85            }
86            if let Type::Slice(s) = r.elem.as_ref() {
87                let inner = wit_type_with(&s.elem, known)?;
88                return Ok(format!("list<{inner}>"));
89            }
90            wit_type_with(&r.elem, known)
91        }
92        Type::Path(p) => {
93            let seg = p
94                .path
95                .segments
96                .last()
97                .ok_or_else(|| "empty type path".to_string())?;
98            let ident = seg.ident.to_string();
99            match ident.as_str() {
100                "bool" => Ok("bool".into()),
101                "i8" => Ok("s8".into()),
102                "i16" => Ok("s16".into()),
103                "i32" => Ok("s32".into()),
104                "i64" => Ok("s64".into()),
105                "u8" => Ok("u8".into()),
106                "u16" => Ok("u16".into()),
107                "u32" => Ok("u32".into()),
108                "u64" => Ok("u64".into()),
109                "f32" => Ok("f32".into()),
110                "f64" => Ok("f64".into()),
111                "char" => Ok("char".into()),
112                "String" => Ok("string".into()),
113                "Vec" => {
114                    let inner = single_generic(seg, "Vec")?;
115                    Ok(format!("list<{}>", wit_type_with(inner, known)?))
116                }
117                "Option" => {
118                    let inner = single_generic(seg, "Option")?;
119                    Ok(format!("option<{}>", wit_type_with(inner, known)?))
120                }
121                // A user type with `#[derive(WitType)]` → its kebab record/variant name.
122                other if known.contains(other) => Ok(to_kebab_case(other)),
123                other => Err(format!(
124                    "type `{other}` is not supported in a WASM fidius interface \
125                     (supported: bool, i8..i64, u8..u64, f32/f64, char, String, Vec<T>, \
126                     Option<T>, Result<T, PluginError>, and #[derive(WitType)] structs/enums)"
127                )),
128            }
129        }
130        Type::Tuple(t) if t.elems.is_empty() => {
131            Err("unit `()` is not a valid argument type".to_string())
132        }
133        _ => Err("unsupported type in a WASM fidius interface".to_string()),
134    }
135}
136
137/// Primitive/std-only mapping (no user types) — the form `fidius-macro` uses for
138/// the descriptor/method tables.
139pub fn rust_type_to_wit(ty: &Type) -> Result<String, String> {
140    wit_type_with(ty, &BTreeSet::new())
141}
142
143/// Map a method's return type to an optional WIT return, with user types in
144/// `known`. `Result<T, PluginError>` → `result<T, plugin-error>` (or
145/// `result<_, plugin-error>` for `T = ()`); `()`/none → no return.
146pub fn return_to_wit_with(
147    ret: Option<&Type>,
148    known: &BTreeSet<String>,
149) -> Result<Option<String>, String> {
150    let Some(ty) = ret else { return Ok(None) };
151    if is_unit(ty) {
152        return Ok(None);
153    }
154    if let Type::Path(p) = ty {
155        if let Some(seg) = p.path.segments.last() {
156            if seg.ident == "Result" {
157                let ok = first_generic(seg).ok_or_else(|| "Result needs type args".to_string())?;
158                let ok_wit = if is_unit(ok) {
159                    "_".to_string()
160                } else {
161                    wit_type_with(ok, known)?
162                };
163                return Ok(Some(format!("result<{ok_wit}, plugin-error>")));
164            }
165        }
166    }
167    Ok(Some(wit_type_with(ty, known)?))
168}
169
170/// Primitive/std-only return mapping (no user types).
171pub fn return_to_wit(ret: Option<&Type>) -> Result<Option<String>, String> {
172    return_to_wit_with(ret, &BTreeSet::new())
173}
174
175/// Render a `record <name> { ... }` WIT block from a Rust struct (named fields
176/// only). Field types are mapped with `known` so they may reference other user
177/// types.
178pub fn struct_to_record(item: &ItemStruct, known: &BTreeSet<String>) -> Result<String, String> {
179    let name = to_kebab_case(&item.ident.to_string());
180    let Fields::Named(fields) = &item.fields else {
181        return Err(format!(
182            "WitType struct `{}` must have named fields (tuple/unit structs are not supported)",
183            item.ident
184        ));
185    };
186    let mut out = format!("    record {name} {{\n");
187    for f in &fields.named {
188        let fname = to_kebab_case(&f.ident.as_ref().unwrap().to_string());
189        let fty = wit_type_with(&f.ty, known)
190            .map_err(|e| format!("field `{}` of `{}`: {e}", fname, item.ident))?;
191        out.push_str(&format!("        {fname}: {fty},\n"));
192    }
193    out.push_str("    }\n");
194    Ok(out)
195}
196
197/// Render a Rust enum to WIT: a `variant <name> { ... }` plus any **synthetic
198/// records** for struct-shaped cases. Returns `(synthetic_records, variant)`.
199///
200/// Case shapes: unit → `case`; single tuple field → `case(type)`; named fields
201/// (`Case { .. }`) → a synthesized `record <enum>-<case>` and `case(<enum>-<case>)`.
202/// A multi-field *tuple* case is rejected: WIT cases take one payload, and a
203/// multi-field tuple serializes as a sequence (not a record) via serde, so it
204/// can't round-trip — use a struct variant `Case { .. }` instead.
205pub fn enum_to_wit(
206    item: &ItemEnum,
207    known: &BTreeSet<String>,
208) -> Result<(Vec<String>, String), String> {
209    let ename = item.ident.to_string();
210    let name = to_kebab_case(&ename);
211    let mut records = Vec::new();
212    let mut out = format!("    variant {name} {{\n");
213    for v in &item.variants {
214        let case = to_kebab_case(&v.ident.to_string());
215        match &v.fields {
216            Fields::Unit => out.push_str(&format!("        {case},\n")),
217            Fields::Unnamed(u) if u.unnamed.len() == 1 => {
218                let payload = wit_type_with(&u.unnamed[0].ty, known)
219                    .map_err(|e| format!("variant `{ename}::{}`: {e}", v.ident))?;
220                out.push_str(&format!("        {case}({payload}),\n"));
221            }
222            Fields::Named(f) => {
223                // Synthesize `record <enum>-<case> { .. }` for the case payload.
224                let rec_name = format!("{name}-{case}");
225                let mut rec = format!("    record {rec_name} {{\n");
226                for fl in &f.named {
227                    let fname = to_kebab_case(&fl.ident.as_ref().unwrap().to_string());
228                    let fty = wit_type_with(&fl.ty, known)
229                        .map_err(|e| format!("field `{fname}` of `{ename}::{}`: {e}", v.ident))?;
230                    rec.push_str(&format!("        {fname}: {fty},\n"));
231                }
232                rec.push_str("    }\n");
233                records.push(rec);
234                out.push_str(&format!("        {case}({rec_name}),\n"));
235            }
236            Fields::Unnamed(_) => {
237                return Err(format!(
238                    "WitType enum `{ename}` variant `{}` has multiple tuple fields; \
239                     a WIT variant case takes one payload — use a struct variant \
240                     `{} {{ .. }}` (or a single field)",
241                    v.ident, v.ident
242                ));
243            }
244        }
245    }
246    out.push_str("    }\n");
247    Ok((records, out))
248}
249
250/// Render a complete `.wit` document: package + interface (the `plugin-error`
251/// record, any user `type_defs` (records/variants), the funcs, and the
252/// `fidius-interface-hash` carrier) + the `<iface>-plugin` world. `type_defs`
253/// are pre-rendered (see [`struct_to_record`] / [`enum_to_variant`]).
254pub fn render_wit_full(iface_kebab: &str, type_defs: &[String], methods: &[WitMethod]) -> String {
255    let mut s = String::new();
256    s.push_str(&format!("package fidius:{iface_kebab}@0.1.0;\n\n"));
257    s.push_str(&format!("interface {iface_kebab} {{\n"));
258    s.push_str("    record plugin-error {\n");
259    s.push_str("        code: string,\n");
260    s.push_str("        message: string,\n");
261    s.push_str("        details: option<string>,\n");
262    s.push_str("    }\n");
263    for def in type_defs {
264        s.push_str(def);
265    }
266    for m in methods {
267        let params = m
268            .params
269            .iter()
270            .map(|(n, t)| format!("{n}: {t}"))
271            .collect::<Vec<_>>()
272            .join(", ");
273        match &m.ret {
274            Some(r) => s.push_str(&format!("    {}: func({params}) -> {r};\n", m.name)),
275            None => s.push_str(&format!("    {}: func({params});\n", m.name)),
276        }
277    }
278    s.push_str("    fidius-interface-hash: func() -> u64;\n");
279    s.push_str("}\n\n");
280    s.push_str(&format!(
281        "world {iface_kebab}-plugin {{\n    export {iface_kebab};\n}}\n"
282    ));
283    s
284}
285
286/// Convenience: render a WIT document with no user type defs (the primitives-only
287/// form `fidius-macro` emits inline today).
288pub fn render_wit(iface_kebab: &str, methods: &[WitMethod]) -> String {
289    render_wit_full(iface_kebab, &[], methods)
290}
291
292// ── helpers ─────────────────────────────────────────────────────────────────
293
294fn is_unit(ty: &Type) -> bool {
295    matches!(ty, Type::Tuple(t) if t.elems.is_empty())
296}
297
298fn path_is(p: &syn::TypePath, name: &str) -> bool {
299    p.path
300        .segments
301        .last()
302        .map(|s| s.ident == name)
303        .unwrap_or(false)
304}
305
306fn single_generic<'a>(seg: &'a syn::PathSegment, what: &str) -> Result<&'a Type, String> {
307    first_generic(seg).ok_or_else(|| format!("`{what}` needs one type argument"))
308}
309
310fn first_generic(seg: &syn::PathSegment) -> Option<&Type> {
311    if let PathArguments::AngleBracketed(ab) = &seg.arguments {
312        for a in &ab.args {
313            if let GenericArgument::Type(t) = a {
314                return Some(t);
315            }
316        }
317    }
318    None
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    fn known(names: &[&str]) -> BTreeSet<String> {
326        names.iter().map(|s| s.to_string()).collect()
327    }
328    fn wit(s: &str) -> String {
329        rust_type_to_wit(&syn::parse_str::<Type>(s).unwrap()).unwrap()
330    }
331
332    #[test]
333    fn primitives_strings_containers() {
334        assert_eq!(wit("i64"), "s64");
335        assert_eq!(wit("u32"), "u32");
336        assert_eq!(wit("String"), "string");
337        assert_eq!(wit("& str"), "string");
338        assert_eq!(wit("Vec<u8>"), "list<u8>");
339        assert_eq!(wit("Option<i32>"), "option<s32>");
340        assert_eq!(wit("&[u8]"), "list<u8>");
341    }
342
343    #[test]
344    fn returns() {
345        let ret = |s: &str| return_to_wit(Some(&syn::parse_str::<Type>(s).unwrap())).unwrap();
346        assert_eq!(ret("()"), None);
347        assert_eq!(
348            ret("Result<i64, PluginError>"),
349            Some("result<s64, plugin-error>".into())
350        );
351        assert_eq!(
352            ret("Result<(), PluginError>"),
353            Some("result<_, plugin-error>".into())
354        );
355    }
356
357    #[test]
358    fn user_types_need_the_known_set() {
359        let ty: Type = syn::parse_str("Point").unwrap();
360        assert!(rust_type_to_wit(&ty).is_err()); // unknown without the set
361        let k = known(&["Point"]);
362        assert_eq!(wit_type_with(&ty, &k).unwrap(), "point");
363        // nested in containers
364        let v: Type = syn::parse_str("Vec<Point>").unwrap();
365        assert_eq!(wit_type_with(&v, &k).unwrap(), "list<point>");
366        let o: Type = syn::parse_str("Option<BytePipe>").unwrap();
367        assert_eq!(
368            wit_type_with(&o, &known(&["BytePipe"])).unwrap(),
369            "option<byte-pipe>"
370        );
371    }
372
373    #[test]
374    fn struct_renders_to_record() {
375        let item: ItemStruct = syn::parse_str("struct Point { x: i32, y_pos: u64 }").unwrap();
376        let rec = struct_to_record(&item, &BTreeSet::new()).unwrap();
377        assert!(rec.contains("record point {"));
378        assert!(rec.contains("x: s32,"));
379        assert!(rec.contains("y-pos: u64,"));
380    }
381
382    #[test]
383    fn struct_with_nested_user_type() {
384        let item: ItemStruct = syn::parse_str("struct Line { from: Point, to: Point }").unwrap();
385        let rec = struct_to_record(&item, &known(&["Point"])).unwrap();
386        assert!(rec.contains("from: point,"));
387        assert!(rec.contains("to: point,"));
388    }
389
390    #[test]
391    fn enum_renders_to_variant() {
392        let item: ItemEnum =
393            syn::parse_str("enum Shape { Circle(u32), Rect(Point), Dot }").unwrap();
394        let (records, var) = enum_to_wit(&item, &known(&["Point"])).unwrap();
395        assert!(records.is_empty());
396        assert!(var.contains("variant shape {"));
397        assert!(var.contains("circle(u32),"));
398        assert!(var.contains("rect(point),"));
399        assert!(var.contains("dot,"));
400    }
401
402    #[test]
403    fn struct_variant_synthesizes_a_record() {
404        let item: ItemEnum = syn::parse_str("enum Shape { Rect { w: u32, h: u32 }, Dot }").unwrap();
405        let (records, var) = enum_to_wit(&item, &BTreeSet::new()).unwrap();
406        assert_eq!(records.len(), 1);
407        assert!(records[0].contains("record shape-rect {"));
408        assert!(records[0].contains("w: u32,"));
409        assert!(records[0].contains("h: u32,"));
410        assert!(var.contains("rect(shape-rect),"));
411        assert!(var.contains("dot,"));
412    }
413
414    #[test]
415    fn multifield_tuple_variant_is_rejected() {
416        let item: ItemEnum = syn::parse_str("enum E { Pair(u32, u32) }").unwrap();
417        assert!(enum_to_wit(&item, &BTreeSet::new()).is_err());
418    }
419
420    #[test]
421    fn full_document_places_type_defs_before_funcs() {
422        let recs = vec![struct_to_record(
423            &syn::parse_str("struct Point { x: i32, y: i32 }").unwrap(),
424            &BTreeSet::new(),
425        )
426        .unwrap()];
427        let methods = vec![WitMethod {
428            name: "midpoint".into(),
429            params: vec![("a".into(), "point".into()), ("b".into(), "point".into())],
430            ret: Some("point".into()),
431        }];
432        let doc = render_wit_full("geo", &recs, &methods);
433        assert!(doc.contains("package fidius:geo@0.1.0;"));
434        let rec_at = doc.find("record point {").unwrap();
435        let fn_at = doc.find("midpoint: func").unwrap();
436        assert!(
437            rec_at < fn_at,
438            "records must precede funcs in the interface"
439        );
440        assert!(doc.contains("midpoint: func(a: point, b: point) -> point;"));
441        assert!(doc.contains("fidius-interface-hash: func() -> u64;"));
442        assert!(doc.contains("world geo-plugin {"));
443    }
444}