Skip to main content

fidius_wit/
generate.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//! Source-parsing WIT generator (FIDIUS-I-0023).
16//!
17//! Parses a plugin crate's Rust source, finds the `#[plugin_interface]` trait and
18//! every `#[derive(WitType)]` `struct`/`enum`, and produces (a) a complete `.wit`
19//! document (records/variants + funcs) and (b) the Rust source for
20//! generated↔author `From` conversions the wasm adapter includes. A proc-macro
21//! can't see external type definitions, so this runs from a `build.rs` helper and
22//! the `fidius wit` CLI, which read the source files.
23
24use std::collections::BTreeSet;
25
26use syn::{FnArg, Item, Pat, ReturnType, TraitItem, Type};
27
28use crate::{
29    enum_to_wit, return_to_wit_with, struct_to_record, to_kebab_case, wit_type_with, WitMethod,
30};
31
32/// The product of generating from a plugin crate's source.
33pub struct Generated {
34    /// The interface's Rust trait name (e.g. `Greeter`).
35    pub interface_name: String,
36    /// kebab-case interface name (the WIT package + interface name).
37    pub iface_kebab: String,
38    /// Rust idents of the `#[derive(WitType)]` user types found.
39    pub user_types: Vec<String>,
40    /// The complete `.wit` document.
41    pub wit: String,
42    /// Rust source for the generated↔author `From` conversions (to be `include!`d
43    /// inside the wasm adapter module). Empty when there are no user types.
44    pub conversions: String,
45}
46
47/// Generate WIT + conversions from a crate's source string (`lib.rs`). Inline
48/// modules (`mod m { .. }`) are walked; external `mod m;` files cannot be read
49/// from a bare string — use [`generate_from_path`] (the `build.rs` helper does).
50pub fn generate(src: &str) -> Result<Generated, String> {
51    let file = syn::parse_file(src).map_err(|e| format!("parse error: {e}"))?;
52    let mut acc = Collected::default();
53    collect(&file.items, &[], None, &mut acc)?;
54    assemble(acc)
55}
56
57/// Like [`generate`], but reads `lib_rs` and follows external `mod m;` files
58/// (resolving `m.rs` / `m/mod.rs`), so `#[derive(WitType)]` types and the
59/// `#[plugin_interface]` trait may live in submodules.
60pub fn generate_from_path(lib_rs: &std::path::Path) -> Result<Generated, String> {
61    let src = std::fs::read_to_string(lib_rs)
62        .map_err(|e| format!("reading {}: {e}", lib_rs.display()))?;
63    let file = syn::parse_file(&src).map_err(|e| format!("parse {}: {e}", lib_rs.display()))?;
64    let dir = lib_rs.parent().unwrap_or_else(|| std::path::Path::new("."));
65    let mut acc = Collected::default();
66    collect(&file.items, &[], Some(dir), &mut acc)?;
67    assemble(acc)
68}
69
70/// `#[derive(WitType)]` types (tagged with their Rust module path) + the
71/// `#[plugin_interface]` trait, gathered across the module tree.
72#[derive(Default)]
73struct Collected {
74    structs: Vec<(Vec<String>, syn::ItemStruct)>,
75    enums: Vec<(Vec<String>, syn::ItemEnum)>,
76    the_trait: Option<syn::ItemTrait>,
77}
78
79/// Recursively gather items, descending into inline `mod m { .. }` and (when
80/// `dir` is `Some`) external `mod m;` files (`m.rs` / `m/mod.rs`).
81fn collect(
82    items: &[Item],
83    mod_path: &[String],
84    dir: Option<&std::path::Path>,
85    acc: &mut Collected,
86) -> Result<(), String> {
87    for item in items {
88        match item {
89            Item::Struct(s) if has_derive(&s.attrs, "WitType") => {
90                acc.structs.push((mod_path.to_vec(), s.clone()));
91            }
92            Item::Enum(e) if has_derive(&e.attrs, "WitType") => {
93                acc.enums.push((mod_path.to_vec(), e.clone()));
94            }
95            Item::Trait(t) if has_attr(&t.attrs, "plugin_interface") => {
96                if acc.the_trait.is_some() {
97                    return Err("multiple #[plugin_interface] traits found".into());
98                }
99                acc.the_trait = Some(t.clone());
100            }
101            Item::Mod(m) => {
102                let mut child = mod_path.to_vec();
103                child.push(m.ident.to_string());
104                if let Some((_, items)) = &m.content {
105                    let sub = dir.map(|d| d.join(m.ident.to_string()));
106                    collect(items, &child, sub.as_deref(), acc)?;
107                } else if let Some(d) = dir {
108                    let name = m.ident.to_string();
109                    let candidates = [d.join(format!("{name}.rs")), d.join(&name).join("mod.rs")];
110                    let file = candidates.iter().find(|p| p.exists()).ok_or_else(|| {
111                        format!(
112                            "cannot find module file for `mod {name};` near {}",
113                            d.display()
114                        )
115                    })?;
116                    let src = std::fs::read_to_string(file)
117                        .map_err(|e| format!("reading {}: {e}", file.display()))?;
118                    let parsed = syn::parse_file(&src)
119                        .map_err(|e| format!("parse {}: {e}", file.display()))?;
120                    collect(&parsed.items, &child, Some(&d.join(&name)), acc)?;
121                }
122            }
123            _ => {}
124        }
125    }
126    Ok(())
127}
128
129/// Build the `.wit` + conversions from the collected items.
130fn assemble(acc: Collected) -> Result<Generated, String> {
131    let the_trait = acc
132        .the_trait
133        .ok_or("no #[plugin_interface] trait found in source")?;
134    let interface_name = the_trait.ident.to_string();
135    let iface_kebab = to_kebab_case(&interface_name);
136
137    let mut user_types: Vec<String> = Vec::new();
138    user_types.extend(acc.structs.iter().map(|(_, s)| s.ident.to_string()));
139    user_types.extend(acc.enums.iter().map(|(_, e)| e.ident.to_string()));
140    let known: BTreeSet<String> = user_types.iter().cloned().collect();
141
142    // Type defs: records (struct records + synthetic struct-variant payload
143    // records) before variants, so forward references resolve cleanly.
144    let mut type_defs: Vec<String> = Vec::new();
145    let mut variant_defs: Vec<String> = Vec::new();
146    for (_, s) in &acc.structs {
147        type_defs.push(struct_to_record(s, &known)?);
148    }
149    for (_, e) in &acc.enums {
150        let (synthetic, variant) = enum_to_wit(e, &known)?;
151        type_defs.extend(synthetic);
152        variant_defs.push(variant);
153    }
154    type_defs.extend(variant_defs);
155
156    let mut methods: Vec<WitMethod> = Vec::new();
157    for item in &the_trait.items {
158        let TraitItem::Fn(f) = item else { continue };
159        let mut params = Vec::new();
160        for arg in &f.sig.inputs {
161            let FnArg::Typed(pt) = arg else { continue }; // skip &self
162
163            // Client-/bidi-streaming: the `Stream<T>` input arg crosses as bincode via the
164            // `fidius:stream-pull` import, not a WIT param — exclude it (FIDIUS-T-0175). The
165            // return-side handling below renders a `Stream` *return* as a resource (bidi).
166            if crate::stream_item_type(&pt.ty).is_some() {
167                continue;
168            }
169            let name = match pt.pat.as_ref() {
170                Pat::Ident(id) => to_kebab_case(&id.ident.to_string()),
171                _ => "arg".to_string(),
172            };
173            let wt = wit_type_with(&pt.ty, &known)
174                .map_err(|e| format!("method `{}` arg `{name}`: {e}", f.sig.ident))?;
175            params.push((name, wt));
176        }
177        let ret_ty: Option<&Type> = match &f.sig.output {
178            ReturnType::Type(_, t) => Some(t.as_ref()),
179            ReturnType::Default => None,
180        };
181        // Server-streaming (`-> fidius::Stream<T>`): the func renders as a
182        // resource-returning export, not a value return (FIDIUS-I-0026).
183        let stream_item = ret_ty.and_then(crate::stream_item_type);
184        let (ret, stream_item) = match stream_item {
185            Some(item_ty) => {
186                let item_wit = wit_type_with(item_ty, &known)
187                    .map_err(|e| format!("method `{}` stream item: {e}", f.sig.ident))?;
188                (None, Some(item_wit))
189            }
190            None => {
191                let ret = return_to_wit_with(ret_ty, &known)
192                    .map_err(|e| format!("method `{}` return: {e}", f.sig.ident))?;
193                (ret, None)
194            }
195        };
196        methods.push(WitMethod {
197            name: to_kebab_case(&f.sig.ident.to_string()),
198            params,
199            ret,
200            stream_item,
201        });
202    }
203
204    let wit = crate::render_wit_full(&iface_kebab, &type_defs, &methods);
205    let conversions = render_conversions(&iface_kebab, &acc.structs, &acc.enums, &known);
206
207    Ok(Generated {
208        interface_name,
209        iface_kebab,
210        user_types,
211        wit,
212        conversions,
213    })
214}
215
216/// `crate::<mod::path>::<Name>` — the author-side path for a type at `mod_path`.
217fn author_path(mod_path: &[String], name: &str) -> String {
218    if mod_path.is_empty() {
219        format!("crate::{name}")
220    } else {
221        format!("crate::{}::{name}", mod_path.join("::"))
222    }
223}
224
225/// Render `From` impls (both directions) between each user type and its
226/// wit-bindgen-generated mirror. Emitted into the adapter module, where the
227/// generated types live (flat) at `exports::fidius::<iface>::<iface>::<Type>`
228/// and the author types at `crate::<mod::path>::<Type>`.
229fn render_conversions(
230    iface_kebab: &str,
231    structs: &[(Vec<String>, syn::ItemStruct)],
232    enums: &[(Vec<String>, syn::ItemEnum)],
233    known: &BTreeSet<String>,
234) -> String {
235    if structs.is_empty() && enums.is_empty() {
236        return String::new();
237    }
238    let snake = iface_kebab.replace('-', "_");
239    let gen_path = format!("exports::fidius::{snake}::{snake}");
240    let mut out = String::new();
241    out.push_str("// Generated by fidius-wit: author <-> wit-bindgen conversions.\n");
242
243    for (path, s) in structs {
244        let name = s.ident.to_string();
245        let g = format!("{gen_path}::{name}");
246        let a = author_path(path, &name);
247        let fields: Vec<String> = match &s.fields {
248            syn::Fields::Named(f) => f
249                .named
250                .iter()
251                .map(|fl| fl.ident.as_ref().unwrap().to_string())
252                .collect(),
253            _ => Vec::new(),
254        };
255        let field_types: Vec<&Type> = match &s.fields {
256            syn::Fields::Named(f) => f.named.iter().map(|fl| &fl.ty).collect(),
257            _ => Vec::new(),
258        };
259        // generated -> author
260        let to_author: Vec<String> = fields
261            .iter()
262            .zip(&field_types)
263            .map(|(f, ty)| format!("{f}: {}", conv_expr(&format!("v.{f}"), ty, known)))
264            .collect();
265        out.push_str(&format!(
266            "impl ::core::convert::From<{g}> for {a} {{ fn from(v: {g}) -> Self {{ {a} {{ {} }} }} }}\n",
267            to_author.join(", ")
268        ));
269        // author -> generated
270        let to_gen: Vec<String> = fields
271            .iter()
272            .zip(&field_types)
273            .map(|(f, ty)| format!("{f}: {}", conv_expr(&format!("v.{f}"), ty, known)))
274            .collect();
275        out.push_str(&format!(
276            "impl ::core::convert::From<{a}> for {g} {{ fn from(v: {a}) -> Self {{ {g} {{ {} }} }} }}\n",
277            to_gen.join(", ")
278        ));
279    }
280
281    for (path, e) in enums {
282        let ename = e.ident.to_string();
283        let g = format!("{gen_path}::{ename}");
284        let a = author_path(path, &ename);
285
286        let mut g2a = Vec::new(); // generated -> author
287        let mut a2g = Vec::new(); // author -> generated
288        for v in &e.variants {
289            let case = v.ident.to_string();
290            match &v.fields {
291                syn::Fields::Unit => {
292                    g2a.push(format!("{g}::{case} => {a}::{case}"));
293                    a2g.push(format!("{a}::{case} => {g}::{case}"));
294                }
295                syn::Fields::Unnamed(u) if u.unnamed.len() == 1 => {
296                    let ty = &u.unnamed[0].ty;
297                    g2a.push(format!(
298                        "{g}::{case}(x) => {a}::{case}({})",
299                        conv_expr("x", ty, known)
300                    ));
301                    a2g.push(format!(
302                        "{a}::{case}(x) => {g}::{case}({})",
303                        conv_expr("x", ty, known)
304                    ));
305                }
306                syn::Fields::Named(f) => {
307                    // The gen side wraps the case payload in a synthesized record
308                    // (`<Enum><Case>`, e.g. `ShapeRect`); the author side is a
309                    // struct variant with inline fields.
310                    let gen_rec = format!("{gen_path}::{ename}{case}");
311                    let fnames: Vec<String> = f
312                        .named
313                        .iter()
314                        .map(|fl| fl.ident.as_ref().unwrap().to_string())
315                        .collect();
316                    let g2a_inits = fnames
317                        .iter()
318                        .zip(f.named.iter())
319                        .map(|(n, fl)| {
320                            format!("{n}: {}", conv_expr(&format!("__r.{n}"), &fl.ty, known))
321                        })
322                        .collect::<Vec<_>>()
323                        .join(", ");
324                    g2a.push(format!("{g}::{case}(__r) => {a}::{case} {{ {g2a_inits} }}"));
325
326                    let binds = fnames.join(", ");
327                    let a2g_inits = fnames
328                        .iter()
329                        .zip(f.named.iter())
330                        .map(|(n, fl)| format!("{n}: {}", conv_expr(n, &fl.ty, known)))
331                        .collect::<Vec<_>>()
332                        .join(", ");
333                    a2g.push(format!(
334                        "{a}::{case} {{ {binds} }} => {g}::{case}({gen_rec} {{ {a2g_inits} }})"
335                    ));
336                }
337                // Multi-field tuple cases are rejected by `enum_to_wit`.
338                syn::Fields::Unnamed(_) => unreachable!("rejected by enum_to_wit"),
339            }
340        }
341        out.push_str(&format!(
342            "impl ::core::convert::From<{g}> for {a} {{ fn from(v: {g}) -> Self {{ match v {{ {} }} }} }}\n",
343            g2a.join(", ")
344        ));
345        out.push_str(&format!(
346            "impl ::core::convert::From<{a}> for {g} {{ fn from(v: {a}) -> Self {{ match v {{ {} }} }} }}\n",
347            a2g.join(", ")
348        ));
349    }
350    out
351}
352
353/// Conversion expression for a field/payload `access` of type `ty`. Identity
354/// (move) when the type holds no user type (generated and author types are then
355/// identical); otherwise `.into()` (user type), or a `map`/`into_iter().map()`
356/// recursing through `Option`/`Vec`. Symmetric — works generated→author and
357/// author→generated, since `From` is generated both ways. Public so the macro's
358/// adapter reuses the exact same boundary conversions.
359pub fn conv_expr(access: &str, ty: &Type, known: &BTreeSet<String>) -> String {
360    // Maps cross as `list<tuple<k, v>>` — a `Vec<(K, V)>` binding. Convert with
361    // `collect()` (bidirectional via type inference: `Vec`↔`HashMap`/`BTreeMap`).
362    // Handle before the user-type short-circuit so a `HashMap<String, u32>` (no
363    // `#[derive(WitType)]` inside) still converts to/from its binding.
364    if let Type::Path(p) = ty {
365        if let Some(seg) = p.path.segments.last() {
366            if matches!(seg.ident.to_string().as_str(), "HashMap" | "BTreeMap") {
367                if let Some((k, v)) = two_generics(seg) {
368                    let kc = conv_expr("k", k, known);
369                    let vc = conv_expr("v", v, known);
370                    if kc == "k" && vc == "v" {
371                        return format!("{access}.into_iter().collect()");
372                    }
373                    return format!("{access}.into_iter().map(|(k, v)| ({kc}, {vc})).collect()");
374                }
375            }
376        }
377    }
378    if !contains_user_type(ty, known) {
379        return access.to_string();
380    }
381    if let Type::Path(p) = ty {
382        if let Some(seg) = p.path.segments.last() {
383            let ident = seg.ident.to_string();
384            if let Some(inner) = single_generic(seg) {
385                match ident.as_str() {
386                    "Vec" => {
387                        return format!(
388                            "{access}.into_iter().map(|w| {}).collect()",
389                            conv_expr("w", inner, known)
390                        );
391                    }
392                    "Option" => {
393                        return format!("{access}.map(|w| {})", conv_expr("w", inner, known));
394                    }
395                    _ => {}
396                }
397            }
398            if known.contains(&ident) {
399                return format!("{access}.into()");
400            }
401        }
402    }
403    access.to_string()
404}
405
406/// Whether `ty` is, or contains (through `Vec`/`Option`/`Box`), a user type in
407/// `known`. Public so the macro can classify args/returns.
408pub fn contains_user_type(ty: &Type, known: &BTreeSet<String>) -> bool {
409    if let Type::Path(p) = ty {
410        if let Some(seg) = p.path.segments.last() {
411            let ident = seg.ident.to_string();
412            if known.contains(&ident) {
413                return true;
414            }
415            if matches!(ident.as_str(), "Vec" | "Option" | "Box") {
416                if let Some(inner) = single_generic(seg) {
417                    return contains_user_type(inner, known);
418                }
419            }
420            if matches!(ident.as_str(), "HashMap" | "BTreeMap") {
421                if let Some((k, v)) = two_generics(seg) {
422                    return contains_user_type(k, known) || contains_user_type(v, known);
423                }
424            }
425        }
426    }
427    false
428}
429
430fn single_generic(seg: &syn::PathSegment) -> Option<&Type> {
431    if let syn::PathArguments::AngleBracketed(ab) = &seg.arguments {
432        for a in &ab.args {
433            if let syn::GenericArgument::Type(t) = a {
434                return Some(t);
435            }
436        }
437    }
438    None
439}
440
441fn two_generics(seg: &syn::PathSegment) -> Option<(&Type, &Type)> {
442    if let syn::PathArguments::AngleBracketed(ab) = &seg.arguments {
443        let types: Vec<&Type> = ab
444            .args
445            .iter()
446            .filter_map(|a| match a {
447                syn::GenericArgument::Type(t) => Some(t),
448                _ => None,
449            })
450            .collect();
451        if types.len() >= 2 {
452            return Some((types[0], types[1]));
453        }
454    }
455    None
456}
457
458/// Does `attrs` contain `#[<name>(...)]` / `#[<path>::<name>]` (last segment match)?
459fn has_attr(attrs: &[syn::Attribute], name: &str) -> bool {
460    attrs.iter().any(|a| {
461        a.path()
462            .segments
463            .last()
464            .map(|s| s.ident == name)
465            .unwrap_or(false)
466    })
467}
468
469/// Does `attrs` contain a `#[derive(... <name> ...)]`?
470fn has_derive(attrs: &[syn::Attribute], name: &str) -> bool {
471    for a in attrs {
472        if !a.path().is_ident("derive") {
473            continue;
474        }
475        let mut found = false;
476        let _ = a.parse_nested_meta(|m| {
477            if m.path
478                .segments
479                .last()
480                .map(|s| s.ident == name)
481                .unwrap_or(false)
482            {
483                found = true;
484            }
485            Ok(())
486        });
487        if found {
488            return true;
489        }
490    }
491    false
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    const SRC: &str = r#"
499        #[derive(WitType)]
500        pub struct Point { pub x: i32, pub y: i32 }
501
502        #[derive(WitType)]
503        pub enum Shape { Circle(u32), Rect(Point), Dot }
504
505        #[plugin_interface(version = 1, crate = "fidius_guest")]
506        pub trait Geo: Send + Sync {
507            fn midpoint(&self, a: Point, b: Point) -> Point;
508            fn classify(&self, pts: Vec<Point>) -> Shape;
509            fn name(&self, s: Shape) -> String;
510        }
511    "#;
512
513    #[test]
514    fn generates_wit_with_records_variants_and_funcs() {
515        let g = generate(SRC).unwrap();
516        assert_eq!(g.interface_name, "Geo");
517        assert_eq!(g.iface_kebab, "geo");
518        assert!(g.user_types.contains(&"Point".to_string()));
519        assert!(g.user_types.contains(&"Shape".to_string()));
520
521        assert!(g.wit.contains("record point {"));
522        assert!(g.wit.contains("variant shape {"));
523        assert!(g.wit.contains("rect(point),"));
524        assert!(g
525            .wit
526            .contains("midpoint: func(a: point, b: point) -> point;"));
527        assert!(g.wit.contains("classify: func(pts: list<point>) -> shape;"));
528        assert!(g.wit.contains("name: func(s: shape) -> string;"));
529        assert!(g.wit.contains("fidius-interface-hash: func() -> u64;"));
530    }
531
532    #[test]
533    fn generates_conversions_both_ways() {
534        let g = generate(SRC).unwrap();
535        let c = &g.conversions;
536        // struct record conversions
537        assert!(c.contains("From<exports::fidius::geo::geo::Point> for crate::Point"));
538        assert!(c.contains("From<crate::Point> for exports::fidius::geo::geo::Point"));
539        // enum variant conversions with nested .into() for Rect(Point)
540        assert!(c.contains("From<exports::fidius::geo::geo::Shape> for crate::Shape"));
541        assert!(
542            c.contains("Rect(x) => crate :: Shape :: Rect")
543                || c.contains("Rect(x) => crate::Shape::Rect")
544        );
545        assert!(c.contains(".into()"));
546    }
547
548    #[test]
549    fn primitive_only_interface_has_no_conversions() {
550        let src = r#"
551            #[plugin_interface(version = 1)]
552            pub trait Greeter { fn greet(&self, name: String) -> String; }
553        "#;
554        let g = generate(src).unwrap();
555        assert!(g.user_types.is_empty());
556        assert!(g.conversions.is_empty());
557        assert!(g.wit.contains("greet: func(name: string) -> string;"));
558    }
559
560    #[test]
561    fn unsupported_type_errors() {
562        // `Box<T>` has no WIT projection (maps/tuples are now supported, so use a
563        // type that genuinely isn't).
564        let src = r#"
565            #[plugin_interface(version = 1)]
566            pub trait T { fn f(&self, x: Box<String>) -> u32; }
567        "#;
568        assert!(generate(src).is_err());
569    }
570}