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`.
35///
36/// A leading `r#` raw-ident prefix is stripped first: it is Rust source syntax
37/// for using a keyword as an identifier (`r#type`), and denotes the bare name
38/// (`type`). Stripping it lets WIT keywords that are *also* Rust keywords
39/// (`type`, `enum`, `use`, `static`, `as`, `async`) reach [`wit_ident`] as their
40/// real name so they can be `%`-escaped. The result is the *semantic* WIT name
41/// (no `%`); apply [`wit_ident`] when writing it into WIT source.
42pub fn to_kebab_case(s: &str) -> String {
43    let s = s.strip_prefix("r#").unwrap_or(s);
44    let mut out = String::new();
45    for (i, ch) in s.chars().enumerate() {
46        if ch == '_' {
47            out.push('-');
48        } else if ch.is_uppercase() {
49            if i != 0 {
50                out.push('-');
51            }
52            out.extend(ch.to_lowercase());
53        } else {
54            out.push(ch);
55        }
56    }
57    out
58}
59
60/// The reserved keywords of the WIT grammar (mirrors wit-parser 0.236's
61/// `ast::lex` keyword table). A generated identifier that collides with one of
62/// these is rejected by the WIT parser unless written with a leading `%`.
63/// Kebab-cased forms are included where the keyword is itself kebab
64/// (`error-context`).
65///
66/// Over-/under-inclusion is safe for consistency — both the host descriptor WIT
67/// and the guest `emit_wit` WIT run through [`wit_ident`], so they always agree —
68/// but this set is kept faithful to the parser so output stays minimal.
69const WIT_KEYWORDS: &[&str] = &[
70    "use",
71    "type",
72    "func",
73    "u8",
74    "u16",
75    "u32",
76    "u64",
77    "s8",
78    "s16",
79    "s32",
80    "s64",
81    "f32",
82    "f64",
83    "char",
84    "resource",
85    "own",
86    "borrow",
87    "record",
88    "flags",
89    "variant",
90    "enum",
91    "bool",
92    "string",
93    "option",
94    "result",
95    "future",
96    "stream",
97    "error-context",
98    "list",
99    "as",
100    "from",
101    "static",
102    "interface",
103    "tuple",
104    "world",
105    "import",
106    "export",
107    "package",
108    "constructor",
109    "include",
110    "with",
111    "async",
112];
113
114/// Whether `ident` (an already-kebab-cased WIT identifier) is a reserved WIT
115/// keyword and so must be `%`-escaped when written into WIT source.
116pub fn is_wit_keyword(ident: &str) -> bool {
117    WIT_KEYWORDS.contains(&ident)
118}
119
120/// Escape a generated WIT identifier so it is valid even when it collides with a
121/// WIT keyword: `stream` → `%stream`, `row` → `row`.
122///
123/// The leading `%` is WIT *source syntax* only — `parse_id` strips it and the
124/// identifier it denotes is unchanged. So this is applied **only** when
125/// rendering WIT text; runtime export-name lookups and the interface hash use
126/// the un-escaped name (and the hash derives from Rust signatures regardless).
127/// This is the single source of truth shared by the host descriptor WIT and the
128/// guest `emit_wit` output, so the two can never disagree on a keyword field.
129pub fn wit_ident(ident: &str) -> String {
130    if is_wit_keyword(ident) {
131        format!("%{ident}")
132    } else {
133        ident.to_string()
134    }
135}
136
137/// Extract the `T` from `Result<T, _>`, if `ty` is a `Result`.
138pub fn result_ok_type(ty: &Type) -> Option<&Type> {
139    if let Type::Path(p) = ty {
140        if let Some(seg) = p.path.segments.last() {
141            if seg.ident == "Result" {
142                return first_generic(seg);
143            }
144        }
145    }
146    None
147}
148
149/// One method projected to WIT (already-mapped strings).
150pub struct WitMethod {
151    /// kebab-case export name.
152    pub name: String,
153    /// `(param-name, wit-type)` pairs, in order.
154    pub params: Vec<(String, String)>,
155    /// WIT return type, or `None` for no return.
156    pub ret: Option<String>,
157    /// For a **server-streaming** method (`-> fidius::Stream<T>`): the WIT item
158    /// type `T` (FIDIUS-I-0026). When `Some`, the method renders as an exported
159    /// `resource <name>-stream { next: func() -> result<option<T>, plugin-error>; }`
160    /// and the func returns that resource (`-> <name>-stream`). `None` for a
161    /// normal func.
162    pub stream_item: Option<String>,
163}
164
165/// If `ty` is `fidius::Stream<T>` (final path segment `Stream`, exactly one type
166/// argument), return `T`. The server-streaming marker (FIDIUS-I-0026, D4). Public
167/// so the macro's WASM adapter shares the same detection.
168pub fn stream_item_type(ty: &Type) -> Option<&Type> {
169    if let Type::Path(p) = ty {
170        if let Some(seg) = p.path.segments.last() {
171            if seg.ident == "Stream" {
172                return first_generic(seg);
173            }
174        }
175    }
176    None
177}
178
179/// Map a Rust argument/return type to its WIT spelling, where `known` holds the
180/// Rust identifiers of `#[derive(WitType)]` user types (mapped to their
181/// kebab-case record/variant name). Returns `Err(msg)` for unsupported types.
182pub fn wit_type_with(ty: &Type, known: &BTreeSet<String>) -> Result<String, String> {
183    match ty {
184        // Peel references: `&str` → string, `&[u8]` → list<u8>, `&T` → wit(T).
185        Type::Reference(r) => {
186            if let Type::Path(p) = r.elem.as_ref() {
187                if path_is(p, "str") {
188                    return Ok("string".to_string());
189                }
190            }
191            if let Type::Slice(s) = r.elem.as_ref() {
192                let inner = wit_type_with(&s.elem, known)?;
193                return Ok(format!("list<{inner}>"));
194            }
195            wit_type_with(&r.elem, known)
196        }
197        Type::Path(p) => {
198            let seg = p
199                .path
200                .segments
201                .last()
202                .ok_or_else(|| "empty type path".to_string())?;
203            let ident = seg.ident.to_string();
204            match ident.as_str() {
205                "bool" => Ok("bool".into()),
206                "i8" => Ok("s8".into()),
207                "i16" => Ok("s16".into()),
208                "i32" => Ok("s32".into()),
209                "i64" => Ok("s64".into()),
210                "u8" => Ok("u8".into()),
211                "u16" => Ok("u16".into()),
212                "u32" => Ok("u32".into()),
213                "u64" => Ok("u64".into()),
214                "f32" => Ok("f32".into()),
215                "f64" => Ok("f64".into()),
216                "char" => Ok("char".into()),
217                "String" => Ok("string".into()),
218                "Vec" => {
219                    let inner = single_generic(seg, "Vec")?;
220                    Ok(format!("list<{}>", wit_type_with(inner, known)?))
221                }
222                "Option" => {
223                    let inner = single_generic(seg, "Option")?;
224                    Ok(format!("option<{}>", wit_type_with(inner, known)?))
225                }
226                // Maps have no native WIT type; project to a list of key/value
227                // pairs — `list<tuple<k, v>>` — which round-trips any key type
228                // (not just strings). Insertion order is not preserved.
229                "HashMap" | "BTreeMap" => {
230                    let (k, v) = two_generics(seg, &ident)?;
231                    Ok(format!(
232                        "list<tuple<{}, {}>>",
233                        wit_type_with(k, known)?,
234                        wit_type_with(v, known)?
235                    ))
236                }
237                // A user type with `#[derive(WitType)]` → its kebab record/variant
238                // name (escaped to match its `%`-escaped declaration).
239                other if known.contains(other) => Ok(wit_ident(&to_kebab_case(other))),
240                other => Err(format!(
241                    "type `{other}` is not supported in a WASM fidius interface \
242                     (supported: bool, i8..i64, u8..u64, f32/f64, char, String, Vec<T>, \
243                     Option<T>, HashMap/BTreeMap<K, V>, tuples, Result<T, PluginError>, \
244                     and #[derive(WitType)] structs/enums)"
245                )),
246            }
247        }
248        Type::Tuple(t) if t.elems.is_empty() => {
249            Err("unit `()` is not a valid argument type".to_string())
250        }
251        // Non-empty tuple `(A, B, …)` → WIT `tuple<a, b, …>`.
252        Type::Tuple(t) => {
253            let elems = t
254                .elems
255                .iter()
256                .map(|e| wit_type_with(e, known))
257                .collect::<Result<Vec<_>, _>>()?;
258            Ok(format!("tuple<{}>", elems.join(", ")))
259        }
260        _ => Err("unsupported type in a WASM fidius interface".to_string()),
261    }
262}
263
264/// Primitive/std-only mapping (no user types) — the form `fidius-macro` uses for
265/// the descriptor/method tables.
266pub fn rust_type_to_wit(ty: &Type) -> Result<String, String> {
267    wit_type_with(ty, &BTreeSet::new())
268}
269
270/// Map a method's return type to an optional WIT return, with user types in
271/// `known`. `Result<T, PluginError>` → `result<T, plugin-error>` (or
272/// `result<_, plugin-error>` for `T = ()`); `()`/none → no return.
273pub fn return_to_wit_with(
274    ret: Option<&Type>,
275    known: &BTreeSet<String>,
276) -> Result<Option<String>, String> {
277    let Some(ty) = ret else { return Ok(None) };
278    if is_unit(ty) {
279        return Ok(None);
280    }
281    if let Type::Path(p) = ty {
282        if let Some(seg) = p.path.segments.last() {
283            if seg.ident == "Result" {
284                let ok = first_generic(seg).ok_or_else(|| "Result needs type args".to_string())?;
285                let ok_wit = if is_unit(ok) {
286                    "_".to_string()
287                } else {
288                    wit_type_with(ok, known)?
289                };
290                return Ok(Some(format!("result<{ok_wit}, plugin-error>")));
291            }
292        }
293    }
294    Ok(Some(wit_type_with(ty, known)?))
295}
296
297/// Primitive/std-only return mapping (no user types).
298pub fn return_to_wit(ret: Option<&Type>) -> Result<Option<String>, String> {
299    return_to_wit_with(ret, &BTreeSet::new())
300}
301
302/// Render a `record <name> { ... }` WIT block from a Rust struct (named fields
303/// only). Field types are mapped with `known` so they may reference other user
304/// types.
305pub fn struct_to_record(item: &ItemStruct, known: &BTreeSet<String>) -> Result<String, String> {
306    let name = to_kebab_case(&item.ident.to_string());
307    let Fields::Named(fields) = &item.fields else {
308        return Err(format!(
309            "WitType struct `{}` must have named fields (tuple/unit structs are not supported)",
310            item.ident
311        ));
312    };
313    let mut out = format!("    record {} {{\n", wit_ident(&name));
314    for f in &fields.named {
315        let fname = to_kebab_case(&f.ident.as_ref().unwrap().to_string());
316        let fty = wit_type_with(&f.ty, known)
317            .map_err(|e| format!("field `{}` of `{}`: {e}", fname, item.ident))?;
318        out.push_str(&format!("        {}: {fty},\n", wit_ident(&fname)));
319    }
320    out.push_str("    }\n");
321    Ok(out)
322}
323
324/// Render a Rust enum to WIT: a `variant <name> { ... }` plus any **synthetic
325/// records** for struct-shaped cases. Returns `(synthetic_records, variant)`.
326///
327/// Case shapes: unit → `case`; single tuple field → `case(type)`; named fields
328/// (`Case { .. }`) → a synthesized `record <enum>-<case>` and `case(<enum>-<case>)`.
329/// A multi-field *tuple* case is rejected: WIT cases take one payload, and a
330/// multi-field tuple serializes as a sequence (not a record) via serde, so it
331/// can't round-trip — use a struct variant `Case { .. }` instead.
332pub fn enum_to_wit(
333    item: &ItemEnum,
334    known: &BTreeSet<String>,
335) -> Result<(Vec<String>, String), String> {
336    let ename = item.ident.to_string();
337    let name = to_kebab_case(&ename);
338    let mut records = Vec::new();
339    let mut out = format!("    variant {} {{\n", wit_ident(&name));
340    for v in &item.variants {
341        let case = to_kebab_case(&v.ident.to_string());
342        match &v.fields {
343            Fields::Unit => out.push_str(&format!("        {},\n", wit_ident(&case))),
344            Fields::Unnamed(u) if u.unnamed.len() == 1 => {
345                let payload = wit_type_with(&u.unnamed[0].ty, known)
346                    .map_err(|e| format!("variant `{ename}::{}`: {e}", v.ident))?;
347                out.push_str(&format!("        {}({payload}),\n", wit_ident(&case)));
348            }
349            Fields::Named(f) => {
350                // Synthesize `record <enum>-<case> { .. }` for the case payload.
351                // The compound `<name>-<case>` cannot collide with a keyword (it
352                // always contains a `-`), so it needs no escaping — but its own
353                // field names do.
354                let rec_name = format!("{name}-{case}");
355                let mut rec = format!("    record {rec_name} {{\n");
356                for fl in &f.named {
357                    let fname = to_kebab_case(&fl.ident.as_ref().unwrap().to_string());
358                    let fty = wit_type_with(&fl.ty, known)
359                        .map_err(|e| format!("field `{fname}` of `{ename}::{}`: {e}", v.ident))?;
360                    rec.push_str(&format!("        {}: {fty},\n", wit_ident(&fname)));
361                }
362                rec.push_str("    }\n");
363                records.push(rec);
364                out.push_str(&format!("        {}({rec_name}),\n", wit_ident(&case)));
365            }
366            Fields::Unnamed(_) => {
367                return Err(format!(
368                    "WitType enum `{ename}` variant `{}` has multiple tuple fields; \
369                     a WIT variant case takes one payload — use a struct variant \
370                     `{} {{ .. }}` (or a single field)",
371                    v.ident, v.ident
372                ));
373            }
374        }
375    }
376    out.push_str("    }\n");
377    Ok((records, out))
378}
379
380/// Render a complete `.wit` document: package + interface (the `plugin-error`
381/// record, any user `type_defs` (records/variants), the funcs, and the
382/// `fidius-interface-hash` carrier) + the `<iface>-plugin` world. `type_defs`
383/// are pre-rendered (see [`struct_to_record`] / [`enum_to_variant`]).
384pub fn render_wit_full(iface_kebab: &str, type_defs: &[String], methods: &[WitMethod]) -> String {
385    // The interface name (derived from the trait) is itself an emitted identifier
386    // — a trait named e.g. `Stream` kebabs to a keyword. Escape it everywhere it
387    // appears bare (package name, interface decl, `export`); the `<iface>-plugin`
388    // world name is compound and cannot collide.
389    let iface = wit_ident(iface_kebab);
390    let mut s = String::new();
391    s.push_str(&format!("package fidius:{iface}@0.1.0;\n\n"));
392    s.push_str(&format!("interface {iface} {{\n"));
393    s.push_str("    record plugin-error {\n");
394    s.push_str("        code: string,\n");
395    s.push_str("        message: string,\n");
396    s.push_str("        details: option<string>,\n");
397    s.push_str("    }\n");
398    for def in type_defs {
399        s.push_str(def);
400    }
401    // Resource declarations for streaming methods, before the funcs that return
402    // them (FIDIUS-I-0026): `resource <m>-stream { next: ... }`.
403    for m in methods {
404        if let Some(item) = &m.stream_item {
405            s.push_str(&format!("    resource {}-stream {{\n", m.name));
406            s.push_str(&format!(
407                "        next: func() -> result<option<{item}>, plugin-error>;\n"
408            ));
409            s.push_str("    }\n");
410        }
411    }
412    for m in methods {
413        let params = m
414            .params
415            .iter()
416            .map(|(n, t)| format!("{}: {t}", wit_ident(n)))
417            .collect::<Vec<_>>()
418            .join(", ");
419        // Func name is a bare identifier (escape); the `<name>-stream` resource
420        // reference is compound and cannot collide.
421        let fname = wit_ident(&m.name);
422        if m.stream_item.is_some() {
423            // Streaming: the func returns the owned stream resource.
424            s.push_str(&format!(
425                "    {fname}: func({params}) -> {}-stream;\n",
426                m.name
427            ));
428        } else {
429            match &m.ret {
430                Some(r) => s.push_str(&format!("    {fname}: func({params}) -> {r};\n")),
431                None => s.push_str(&format!("    {fname}: func({params});\n")),
432            }
433        }
434    }
435    s.push_str("    fidius-interface-hash: func() -> u64;\n");
436    // FIDIUS-A-0006 / CI.3: configured-instance constructor. The host calls it
437    // once to bind config (empty bytes for a zero-config plugin); the guest sets
438    // its instance. A carrier like fidius-interface-hash — not part of the
439    // interface hash (which derives from the trait method signatures).
440    s.push_str("    fidius-configure: func(config: list<u8>);\n");
441    s.push_str("}\n\n");
442    s.push_str(&format!(
443        "world {iface_kebab}-plugin {{\n    export {iface};\n}}\n"
444    ));
445    s
446}
447
448/// Convenience: render a WIT document with no user type defs (the primitives-only
449/// form `fidius-macro` emits inline today).
450pub fn render_wit(iface_kebab: &str, methods: &[WitMethod]) -> String {
451    render_wit_full(iface_kebab, &[], methods)
452}
453
454// ── helpers ─────────────────────────────────────────────────────────────────
455
456fn is_unit(ty: &Type) -> bool {
457    matches!(ty, Type::Tuple(t) if t.elems.is_empty())
458}
459
460fn path_is(p: &syn::TypePath, name: &str) -> bool {
461    p.path
462        .segments
463        .last()
464        .map(|s| s.ident == name)
465        .unwrap_or(false)
466}
467
468fn single_generic<'a>(seg: &'a syn::PathSegment, what: &str) -> Result<&'a Type, String> {
469    first_generic(seg).ok_or_else(|| format!("`{what}` needs one type argument"))
470}
471
472fn first_generic(seg: &syn::PathSegment) -> Option<&Type> {
473    if let PathArguments::AngleBracketed(ab) = &seg.arguments {
474        for a in &ab.args {
475            if let GenericArgument::Type(t) = a {
476                return Some(t);
477            }
478        }
479    }
480    None
481}
482
483/// Extract the first two type arguments (e.g. the key and value of a `Map<K, V>`).
484fn two_generics<'a>(seg: &'a syn::PathSegment, what: &str) -> Result<(&'a Type, &'a Type), String> {
485    if let PathArguments::AngleBracketed(ab) = &seg.arguments {
486        let types: Vec<&Type> = ab
487            .args
488            .iter()
489            .filter_map(|a| match a {
490                GenericArgument::Type(t) => Some(t),
491                _ => None,
492            })
493            .collect();
494        if types.len() >= 2 {
495            return Ok((types[0], types[1]));
496        }
497    }
498    Err(format!("`{what}` needs two type arguments (key, value)"))
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504
505    fn known(names: &[&str]) -> BTreeSet<String> {
506        names.iter().map(|s| s.to_string()).collect()
507    }
508    fn wit(s: &str) -> String {
509        rust_type_to_wit(&syn::parse_str::<Type>(s).unwrap()).unwrap()
510    }
511
512    #[test]
513    fn primitives_strings_containers() {
514        assert_eq!(wit("i64"), "s64");
515        assert_eq!(wit("u32"), "u32");
516        assert_eq!(wit("String"), "string");
517        assert_eq!(wit("& str"), "string");
518        assert_eq!(wit("Vec<u8>"), "list<u8>");
519        assert_eq!(wit("Option<i32>"), "option<s32>");
520        assert_eq!(wit("&[u8]"), "list<u8>");
521    }
522
523    #[test]
524    fn maps_tuples_and_nesting() {
525        // Maps → list<tuple<k, v>> (any key type, not just strings).
526        assert_eq!(wit("HashMap<String, i64>"), "list<tuple<string, s64>>");
527        assert_eq!(wit("BTreeMap<u32, String>"), "list<tuple<u32, string>>");
528        // Tuples → tuple<...>.
529        assert_eq!(wit("(i32, String)"), "tuple<s32, string>");
530        assert_eq!(wit("(u8, u8, bool)"), "tuple<u8, u8, bool>");
531        // Nesting composes recursively.
532        assert_eq!(wit("Vec<Option<i32>>"), "list<option<s32>>");
533        assert_eq!(wit("Option<Vec<u8>>"), "option<list<u8>>");
534        assert_eq!(
535            wit("HashMap<String, Vec<i64>>"),
536            "list<tuple<string, list<s64>>>"
537        );
538        // Map/tuple carrying a user record (kebab) via the known set.
539        let k = known(&["Row"]);
540        let wk = |s: &str| wit_type_with(&syn::parse_str::<Type>(s).unwrap(), &k).unwrap();
541        assert_eq!(wk("Vec<Row>"), "list<row>");
542        assert_eq!(wk("HashMap<String, Row>"), "list<tuple<string, row>>");
543        assert_eq!(wk("(Row, i32)"), "tuple<row, s32>");
544    }
545
546    #[test]
547    fn returns() {
548        let ret = |s: &str| return_to_wit(Some(&syn::parse_str::<Type>(s).unwrap())).unwrap();
549        assert_eq!(ret("()"), None);
550        assert_eq!(
551            ret("Result<i64, PluginError>"),
552            Some("result<s64, plugin-error>".into())
553        );
554        assert_eq!(
555            ret("Result<(), PluginError>"),
556            Some("result<_, plugin-error>".into())
557        );
558    }
559
560    #[test]
561    fn user_types_need_the_known_set() {
562        let ty: Type = syn::parse_str("Point").unwrap();
563        assert!(rust_type_to_wit(&ty).is_err()); // unknown without the set
564        let k = known(&["Point"]);
565        assert_eq!(wit_type_with(&ty, &k).unwrap(), "point");
566        // nested in containers
567        let v: Type = syn::parse_str("Vec<Point>").unwrap();
568        assert_eq!(wit_type_with(&v, &k).unwrap(), "list<point>");
569        let o: Type = syn::parse_str("Option<BytePipe>").unwrap();
570        assert_eq!(
571            wit_type_with(&o, &known(&["BytePipe"])).unwrap(),
572            "option<byte-pipe>"
573        );
574    }
575
576    #[test]
577    fn struct_renders_to_record() {
578        let item: ItemStruct = syn::parse_str("struct Point { x: i32, y_pos: u64 }").unwrap();
579        let rec = struct_to_record(&item, &BTreeSet::new()).unwrap();
580        assert!(rec.contains("record point {"));
581        assert!(rec.contains("x: s32,"));
582        assert!(rec.contains("y-pos: u64,"));
583    }
584
585    #[test]
586    fn struct_with_nested_user_type() {
587        let item: ItemStruct = syn::parse_str("struct Line { from: Point, to: Point }").unwrap();
588        let rec = struct_to_record(&item, &known(&["Point"])).unwrap();
589        assert!(rec.contains("from: point,"));
590        assert!(rec.contains("to: point,"));
591    }
592
593    #[test]
594    fn enum_renders_to_variant() {
595        let item: ItemEnum =
596            syn::parse_str("enum Shape { Circle(u32), Rect(Point), Dot }").unwrap();
597        let (records, var) = enum_to_wit(&item, &known(&["Point"])).unwrap();
598        assert!(records.is_empty());
599        assert!(var.contains("variant shape {"));
600        assert!(var.contains("circle(u32),"));
601        assert!(var.contains("rect(point),"));
602        assert!(var.contains("dot,"));
603    }
604
605    #[test]
606    fn struct_variant_synthesizes_a_record() {
607        let item: ItemEnum = syn::parse_str("enum Shape { Rect { w: u32, h: u32 }, Dot }").unwrap();
608        let (records, var) = enum_to_wit(&item, &BTreeSet::new()).unwrap();
609        assert_eq!(records.len(), 1);
610        assert!(records[0].contains("record shape-rect {"));
611        assert!(records[0].contains("w: u32,"));
612        assert!(records[0].contains("h: u32,"));
613        assert!(var.contains("rect(shape-rect),"));
614        assert!(var.contains("dot,"));
615    }
616
617    #[test]
618    fn multifield_tuple_variant_is_rejected() {
619        let item: ItemEnum = syn::parse_str("enum E { Pair(u32, u32) }").unwrap();
620        assert!(enum_to_wit(&item, &BTreeSet::new()).is_err());
621    }
622
623    #[test]
624    fn full_document_places_type_defs_before_funcs() {
625        let recs = vec![struct_to_record(
626            &syn::parse_str("struct Point { x: i32, y: i32 }").unwrap(),
627            &BTreeSet::new(),
628        )
629        .unwrap()];
630        let methods = vec![WitMethod {
631            name: "midpoint".into(),
632            params: vec![("a".into(), "point".into()), ("b".into(), "point".into())],
633            ret: Some("point".into()),
634            stream_item: None,
635        }];
636        let doc = render_wit_full("geo", &recs, &methods);
637        assert!(doc.contains("package fidius:geo@0.1.0;"));
638        let rec_at = doc.find("record point {").unwrap();
639        let fn_at = doc.find("midpoint: func").unwrap();
640        assert!(
641            rec_at < fn_at,
642            "records must precede funcs in the interface"
643        );
644        assert!(doc.contains("midpoint: func(a: point, b: point) -> point;"));
645        assert!(doc.contains("fidius-interface-hash: func() -> u64;"));
646        assert!(doc.contains("world geo-plugin {"));
647    }
648
649    #[test]
650    fn streaming_method_renders_a_resource() {
651        let methods = vec![WitMethod {
652            name: "tick".into(),
653            params: vec![("count".into(), "u32".into())],
654            ret: None,
655            stream_item: Some("u64".into()),
656        }];
657        let doc = render_wit("ticker", &methods);
658        // Resource declared, with the poll method...
659        assert!(doc.contains("resource tick-stream {"));
660        assert!(doc.contains("next: func() -> result<option<u64>, plugin-error>;"));
661        // ...and the func returns the owned resource.
662        assert!(doc.contains("tick: func(count: u32) -> tick-stream;"));
663        // Resource precedes the func that returns it.
664        assert!(doc.find("resource tick-stream").unwrap() < doc.find("tick: func").unwrap());
665    }
666
667    #[test]
668    fn keyword_idents_are_escaped_others_untouched() {
669        assert_eq!(wit_ident("stream"), "%stream");
670        assert_eq!(wit_ident("record"), "%record");
671        assert_eq!(wit_ident("from"), "%from");
672        assert_eq!(wit_ident("error-context"), "%error-context");
673        assert_eq!(wit_ident("row"), "row");
674        assert_eq!(wit_ident("dead-letter"), "dead-letter");
675        assert!(is_wit_keyword("result") && !is_wit_keyword("results"));
676    }
677
678    #[test]
679    fn raw_idents_lose_their_prefix_then_escape() {
680        // `r#type` is a WIT keyword that is *also* a Rust keyword.
681        assert_eq!(to_kebab_case("r#type"), "type");
682        assert_eq!(wit_ident(&to_kebab_case("r#type")), "%type");
683        assert_eq!(wit_ident(&to_kebab_case("r#async")), "%async");
684        // a raw ident that isn't a WIT keyword still drops `r#`.
685        assert_eq!(to_kebab_case("r#match"), "match");
686    }
687
688    #[test]
689    fn record_with_keyword_fields_escapes_field_and_record_names() {
690        // `record`, `stream`, `from`, `type` are all WIT keywords.
691        let item: ItemStruct =
692            syn::parse_str("struct Record { record: String, stream: u64, from: bool, r#type: u8 }")
693                .unwrap();
694        let rec = struct_to_record(&item, &BTreeSet::new()).unwrap();
695        assert!(rec.contains("record %record {"), "got: {rec}");
696        assert!(rec.contains("%record: string,"));
697        assert!(rec.contains("%stream: u64,"));
698        assert!(rec.contains("%from: bool,"));
699        assert!(rec.contains("%type: u8,"));
700    }
701
702    #[test]
703    fn variant_with_keyword_cases_escapes_them() {
704        let item: ItemEnum =
705            syn::parse_str("enum Variant { Stream, Record(u32), List { from: u8 } }").unwrap();
706        let (records, var) = enum_to_wit(&item, &BTreeSet::new()).unwrap();
707        assert!(var.contains("variant %variant {"), "got: {var}");
708        assert!(var.contains("%stream,"));
709        assert!(var.contains("%record(u32),"));
710        // struct-variant payload record is compound (`variant-list`) → not escaped,
711        // but its keyword field is.
712        assert!(var.contains("%list(variant-list),"));
713        assert!(records[0].contains("record variant-list {"));
714        assert!(records[0].contains("%from: u8,"));
715    }
716
717    #[test]
718    fn keyword_user_type_reference_matches_its_declaration() {
719        // A field whose *type* is a keyword-named user record must reference the
720        // same `%`-escaped name the record is declared with.
721        let k = known(&["Record"]);
722        let ty: Type = syn::parse_str("Vec<Record>").unwrap();
723        assert_eq!(wit_type_with(&ty, &k).unwrap(), "list<%record>");
724    }
725
726    #[test]
727    fn stream_item_type_detects_marker() {
728        let ty: Type = syn::parse_str("fidius::Stream<u64>").unwrap();
729        let item = stream_item_type(&ty).unwrap();
730        assert_eq!(rust_type_to_wit(item).unwrap(), "u64");
731        let bare: Type = syn::parse_str("Stream<String>").unwrap();
732        assert!(stream_item_type(&bare).is_some());
733        let not: Type = syn::parse_str("Vec<u64>").unwrap();
734        assert!(stream_item_type(&not).is_none());
735    }
736}