Skip to main content

yog_cli/
exports.rs

1//! `yog publish exports` — the `-exports` crate generated from a mod's
2//! `#[yog_export]` items.
3
4use std::collections::HashSet;
5use std::fmt::Write as _;
6use std::path::{Path, PathBuf};
7
8/// What a `#[yog_export]` item is.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum ExportKind {
11    Struct,
12    Enum,
13    Fn,
14}
15
16/// Item found by the export scanner.
17#[derive(Debug)]
18pub struct ExportItem {
19    pub kind: ExportKind,
20    pub name: String,
21    /// Full Rust source of the item (without `#[yog_export]`).
22    pub source: String,
23}
24
25/// Every `#[yog_export]` item of one source file (inline modules too),
26/// read with `syn`: its exact text — comments, layout — without the
27/// `#[yog_export]` attribute itself. A file that doesn't parse has none
28/// (the build reports it).
29pub fn scan(src: &str) -> Vec<ExportItem> {
30    let Ok(file) = syn::parse_file(src) else {
31        return Vec::new();
32    };
33    let mut out = Vec::new();
34    collect(&file.items, src, &mut out);
35    out
36}
37
38fn collect(items: &[syn::Item], src: &str, out: &mut Vec<ExportItem>) {
39    use syn::spanned::Spanned;
40    for item in items {
41        let (attrs, vis, kind, name) = match item {
42            syn::Item::Struct(s) => (&s.attrs, &s.vis, ExportKind::Struct, s.ident.to_string()),
43            syn::Item::Enum(e) => (&e.attrs, &e.vis, ExportKind::Enum, e.ident.to_string()),
44            syn::Item::Fn(f) => (&f.attrs, &f.vis, ExportKind::Fn, f.sig.ident.to_string()),
45            syn::Item::Mod(m) => {
46                if let Some((_, inner)) = &m.content {
47                    collect(inner, src, out);
48                }
49                continue;
50            }
51            _ => continue,
52        };
53        let is_export = |a: &syn::Attribute| a.path().segments.last().is_some_and(|s| s.ident == "yog_export");
54        if !attrs.iter().any(is_export) || !matches!(vis, syn::Visibility::Public(_)) {
55            continue;
56        }
57        let whole = item.span().byte_range();
58        // The item's text with each `#[yog_export]` (and the line it leaves empty) cut out.
59        let mut text = String::new();
60        let mut at = whole.start;
61        for a in attrs.iter().filter(|a| is_export(a)) {
62            let r = a.span().byte_range();
63            text.push_str(&src[at..r.start]);
64            at = r.end;
65            let rest = &src[at..whole.end];
66            let blank = rest.len() - rest.trim_start_matches([' ', '\t']).len();
67            if rest[blank..].starts_with('\n') {
68                at += blank + 1;
69            }
70        }
71        text.push_str(&src[at..whole.end]);
72        // Indentation of nested items stays as written; the text starts at the item.
73        let source = format!("{}\n", text.trim_start());
74        out.push(ExportItem { kind, name, source });
75    }
76}
77
78/// Names always in scope — primitives and the std/core/alloc prelude and
79/// modules — that never need an import.
80const PRIMITIVES: &[&str] = &[
81    "bool",
82    "u8",
83    "u16",
84    "u32",
85    "u64",
86    "u128",
87    "usize",
88    "i8",
89    "i16",
90    "i32",
91    "i64",
92    "i128",
93    "isize",
94    "f32",
95    "f64",
96    "char",
97    "str",
98    "String",
99    "Vec",
100    "Option",
101    "Result",
102    "Box",
103    "Rc",
104    "Arc",
105    "Cow",
106    "PhantomData",
107    "Ordering",
108    "Self",
109    "()",
110    "HashMap",
111    "HashSet",
112    "BTreeMap",
113    "BTreeSet",
114    "LinkedList",
115    "VecDeque",
116    "BinaryHeap",
117    "Path",
118    "PathBuf",
119    "Duration",
120    "Instant",
121    "SystemTime",
122    "Error",
123    "fmt",
124    "ops",
125    "cmp",
126    "iter",
127    "slice",
128    "cell",
129    "sync",
130    "collections",
131    "convert",
132    "marker",
133    "mem",
134    "ptr",
135    "hint",
136    "borrow",
137    "hash",
138    "default",
139    "num",
140    "array",
141    "range",
142    "std::",
143    "core::",
144];
145
146/// Paths into std/core/alloc — always available, imported as "system" uses.
147const SYSTEM_ROOTS: [&str; 3] = ["std::", "core::", "alloc::"];
148
149/// Path roots internal to the mod crate, meaningless in the exports crate.
150const LOCAL_ROOTS: [&str; 3] = ["crate", "super", "self"];
151
152fn is_system(path: &str) -> bool {
153    SYSTEM_ROOTS.iter().any(|p| path.starts_with(p))
154}
155
156/// Collect the imports a generated exports crate needs.
157///
158/// This mirrors how `rustc` resolves names:
159///   1. Is the name a primitive / in `std` / `core` / `alloc` (always in scope)? → no import.
160///   2. Is the name defined in this same crate (an exported struct/enum/fn)? → no import.
161///   3. Otherwise, does the mod's own `use` statements bring it into scope?
162///      → emit the matching `use` path (preferring the most specific one).
163///   4. If it's still unresolved, emit a `use` for the crate the type likely
164///      lives in (e.g. `yog_api::…`) so consumers get a best-effort import.
165///
166/// Returns `(system_uses, type_uses)` where:
167/// - `system_uses`: std/core/alloc `use` paths like `std::collections::HashMap`
168/// - `type_uses`: external `use` paths like `yog_api::registry::Mod`
169pub fn collect_uses(exports: &[ExportItem], sources: &[String]) -> (Vec<String>, Vec<String>) {
170    // 1 + 2: names that never need an import.
171    let primitives: HashSet<&str> = PRIMITIVES.iter().copied().collect();
172
173    // Names defined locally in the mod (exported items) — no import required.
174    let local_names: HashSet<String> = exports.iter().map(|e| e.name.clone()).collect();
175
176    // 3: gather every `use` statement from the mod's source so we can map a
177    //    used type back to its import path.
178    let mut import_paths: Vec<String> = Vec::new(); // e.g. "yog_api::registry::Mod"
179    let mut system_imports: HashSet<String> = HashSet::new();
180    let mut type_imports: HashSet<String> = HashSet::new();
181    for line in sources.iter().flat_map(|src| src.lines()) {
182        let t = line.trim();
183        if !t.starts_with("use ") {
184            continue;
185        }
186        let path_part = t["use ".len()..].trim_end_matches(';').trim();
187        if path_part.is_empty() {
188            continue;
189        }
190        // Skip malformed imports like `yog_api::{` without closing brace.
191        if path_part.contains('{') && !path_part.contains('}') {
192            continue;
193        }
194        // `use foo::{a, b}` / `use foo::*` — keep the prefix as a candidate.
195        if is_system(path_part) {
196            system_imports.insert(path_part.to_string());
197        } else {
198            type_imports.insert(path_part.to_string());
199        }
200        import_paths.push(path_part.to_string());
201    }
202
203    /// Resolve a bare type identifier to the most specific `use` path available.
204    fn resolve_name(
205        name: &str,
206        import_paths: &[String],
207        local_names: &HashSet<String>,
208        primitives: &HashSet<&str>,
209    ) -> Option<String> {
210        // 1. primitive / prelude
211        if primitives.contains(name) {
212            return None;
213        }
214        if is_system(name) {
215            return None;
216        }
217        // 2. defined in this crate
218        if local_names.contains(name) {
219            return None;
220        }
221        // 3. find a `use` path whose tail segment matches `name`.
222        //    Prefer the shortest matching (most specific) import.
223        let mut best: Option<(usize, String)> = None;
224        for p in import_paths {
225            let last = p.rsplit("::").next().unwrap_or(p);
226            if last == name || last == "*" {
227                let len = p.len();
228                if best.as_ref().map(|(l, _)| len < *l).unwrap_or(true) {
229                    best = Some((len, p.clone()));
230                }
231            }
232        }
233        best.map(|(_, p)| p)
234    }
235
236    // 4: scan exported item sources for type identifiers and resolve each.
237    for item in exports {
238        // Strip attributes / bodies to a single flat token-ish view, then pull
239        // out CamelCase identifiers (types) and `crate::`/`super::`/`self::`
240        // / absolute `::` paths.
241        let body = &item.source;
242        // Absolute paths (`::foo::Bar`, `yog_api::…`) are already fully qualified.
243        for m in body.match_indices("::") {
244            let start = m.0;
245            // Find the path: leading `::` or `ident::`, then segments.
246            let slice = &body[start..];
247            let mut end = 0;
248            let mut depth: i32 = 0;
249            for (i, ch) in slice.char_indices() {
250                match ch {
251                    '<' => depth += 1,
252                    '>' => depth -= 1,
253                    ' ' | ',' | ';' | '{' | '}' | '(' | ')' | ':' | '[' | ']' | '.'
254                        if depth == 0 =>
255                    {
256                        end = i;
257                        break;
258                    }
259                    _ => {}
260                }
261                if depth < 0 {
262                    end = i;
263                    break;
264                }
265            }
266            let path = slice[..end].trim().trim_end_matches("::");
267            if path.is_empty() {
268                continue;
269            }
270            let first = path
271                .trim_start_matches(':')
272                .split("::")
273                .next()
274                .unwrap_or("");
275            if is_system(path) {
276                system_imports.insert(path.to_string());
277            } else if first == "yog_api" || LOCAL_ROOTS.contains(&first) {
278                type_imports.insert(path.to_string());
279            }
280        }
281        // Bare CamelCase type names (struct/enum args, generic params, fields).
282        let re_check: Vec<&str> = body
283            .split(|c: char| !c.is_alphanumeric() && c != '_')
284            .filter(|s| !s.is_empty())
285            .collect();
286        for tok in re_check {
287            if tok
288                .chars()
289                .next()
290                .map(|c| c.is_uppercase())
291                .unwrap_or(false)
292                && let Some(p) = resolve_name(tok, &import_paths, &local_names, &primitives)
293            {
294                if is_system(&p) {
295                    system_imports.insert(p);
296                } else {
297                    type_imports.insert(p);
298                }
299            }
300        }
301    }
302
303    // Normalize: merge every `a::b::{X, Y}` / `a::b::X` into one import per
304    // module path, so a name pulled in by several files is imported once.
305    fn normalize(vec: &mut Vec<String>) {
306        let mut groups: std::collections::BTreeMap<String, std::collections::BTreeSet<String>> =
307            Default::default();
308        let mut nested: Vec<String> = Vec::new();
309        for p in vec.drain(..) {
310            let p = p.trim().to_string();
311            if let Some((prefix, rest)) = p.split_once("::{") {
312                let inside = rest.strip_suffix('}').unwrap_or(rest);
313                if inside.contains('{') {
314                    nested.push(p);
315                    continue;
316                }
317                let set = groups.entry(prefix.to_string()).or_default();
318                set.extend(
319                    inside
320                        .split(',')
321                        .map(|s| s.trim())
322                        .filter(|s| !s.is_empty())
323                        .map(str::to_string),
324                );
325            } else if let Some((prefix, item)) = p.rsplit_once("::") {
326                groups
327                    .entry(prefix.to_string())
328                    .or_default()
329                    .insert(item.to_string());
330            } else {
331                nested.push(p);
332            }
333        }
334        let mut keep: Vec<String> = groups
335            .into_iter()
336            .map(|(prefix, items)| {
337                if items.len() == 1 {
338                    format!("{prefix}::{}", items.into_iter().next().unwrap())
339                } else {
340                    let items: Vec<String> = items.into_iter().collect();
341                    format!("{prefix}::{{{}}}", items.join(", "))
342                }
343            })
344            .collect();
345        nested.sort();
346        nested.dedup();
347        keep.extend(nested);
348        keep.sort();
349        *vec = keep;
350    }
351
352    let mut system_vec: Vec<String> = system_imports.into_iter().collect();
353    let mut type_vec: Vec<String> = type_imports.into_iter().collect();
354    normalize(&mut system_vec);
355    normalize(&mut type_vec);
356
357    (system_vec, type_vec)
358}
359
360/// Strip `#[derive(...)]` lines from a Rust source item to avoid duplicates
361/// when the generator adds its own derives.
362pub fn strip_derive_attrs(source: &str) -> String {
363    source
364        .lines()
365        .filter(|line| !line.trim().starts_with("#[derive("))
366        .collect::<Vec<_>>()
367        .join("\n")
368}
369
370/// Generate a full interop wrapper for a `#[yog_export]` function.
371///
372/// Given the source of a function like:
373/// ```text
374/// pub fn register_widget(registry: &mut yog_api::Registry, def: WidgetDef) -> Result<(), String> { ... }
375/// ```
376///
377/// Produces (a leading `&mut Registry`-shaped parameter is dropped — the
378/// exporting mod reconstructs its own `Registry` from the API pointer it
379/// captured at load time, so callers pass only the remaining, natural
380/// arguments; multiple remaining arguments are tupled for serialization):
381/// ```text
382/// #![allow(non_snake_case)]
383/// pub fn register_widget(__yog_arg0: WidgetDef) -> Result<(), String> {
384///     // rkyv-serialize __yog_arg0, call via C-ABI slot, deserialize result
385/// }
386/// static __YOG_SLOT_REGISTER_WIDGET: OnceLock<...>;
387/// #[unsafe(no_mangle)] pub unsafe extern "C" fn __yog_bind_register_widget(ptr) { ... }
388/// ```
389/// An exported fn's parameters (`pattern`, `Type`) and return type, each
390/// exactly as written (doc comments, attributes and wrapped lines are no
391/// trouble: it's parsed). Nothing if it doesn't parse.
392fn fn_signature(source: &str) -> (Vec<(String, String)>, String) {
393    use syn::spanned::Spanned;
394    let Ok(f) = syn::parse_str::<syn::ItemFn>(source) else {
395        return (Vec::new(), "()".into());
396    };
397    let text = |s: proc_macro2::Span| source[s.byte_range()].to_string();
398    let params = f
399        .sig
400        .inputs
401        .iter()
402        .filter_map(|a| match a {
403            syn::FnArg::Typed(t) => Some((text(t.pat.span()), text(t.ty.span()))),
404            syn::FnArg::Receiver(_) => None,
405        })
406        .collect();
407    let output = match &f.sig.output {
408        syn::ReturnType::Default => "()".into(),
409        syn::ReturnType::Type(_, ty) => text(ty.span()),
410    };
411    (params, output)
412}
413
414pub fn generate_fn_wrapper(mod_id: &str, name: &str, source: &str) -> String {
415    let (mut typed_params, output_type) = fn_signature(source);
416
417    // A leading `&mut Registry`-shaped parameter is never part of the public
418    // signature or the serialized payload — the exporting mod reconstructs
419    // its own `Registry` from the API pointer it captured at load time (see
420    // `yog-interop`'s `#[yog_export]`), so callers never need to supply one.
421    if typed_params
422        .first()
423        .map(|(_, ty)| ty.replace(' ', "").ends_with("Registry"))
424        .unwrap_or(false)
425    {
426        typed_params.remove(0);
427    }
428
429    // The export's own parameter names (callers see them); a pattern that
430    // isn't a plain name (`(a, b): (u32, u32)`) gets a made-up one.
431    let arg_names: Vec<String> = typed_params
432        .iter()
433        .enumerate()
434        .map(|(i, (pat, _))| {
435            let name = pat.trim().trim_start_matches("mut ").trim();
436            let plain = !name.is_empty()
437                && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
438                && !name.starts_with(|c: char| c.is_ascii_digit());
439            if plain { name.to_string() } else { format!("__yog_arg{i}") }
440        })
441        .collect();
442    let data_types: Vec<&str> = typed_params.iter().map(|(_, ty)| ty.as_str()).collect();
443
444    let params_sig = arg_names
445        .iter()
446        .zip(data_types.iter())
447        .map(|(n, t)| format!("{n}: {t}"))
448        .collect::<Vec<_>>()
449        .join(", ");
450
451    let tuple_expr = match arg_names.len() {
452        0 => String::new(),
453        1 => arg_names[0].clone(),
454        _ => format!("({})", arg_names.join(", ")),
455    };
456
457    // Statics use SCREAMING_SNAKE_CASE per Rust convention; the bind fn stays
458    // snake_case since it's a function, not a static.
459    let slot_name = format!("__YOG_SLOT_{}", name.to_uppercase());
460    let bind_name = format!("__yog_bind_{}", name);
461
462    // Escape { and } for use in format! — types like `Result<(), String>` contain
463    // curly braces that would be interpreted as format specifiers.
464    let output_type_fmt = output_type.replace('{', "{{").replace('}', "}}");
465    let params_sig_fmt = params_sig.replace('{', "{{").replace('}', "}}");
466    let slot_name_fmt = slot_name.replace('{', "{{").replace('}', "}}");
467    let bind_name_fmt = bind_name.replace('{', "{{").replace('}', "}}");
468
469    // Build the C-ABI function pointer type
470    let wrapper_ty = "::yog_api::YogExportFn".to_string();
471
472    let serialize_block = if tuple_expr.is_empty() {
473        "let input_bytes: Vec<u8> = Vec::new();".to_string()
474    } else {
475        format!(
476            "let aligned = ::yog_api::rkyv::to_bytes::<::yog_api::rkyv::rancor::Error>(\
477                &{tuple_expr}\
478            ).unwrap_or_default();\n            \
479             let input_bytes: Vec<u8> = aligned.to_vec();"
480        )
481    };
482
483    format!(
484        r#"#[allow(non_snake_case)]
485pub fn {name}({params_sig_fmt}) -> {output_type_fmt} {{
486    {serialize_block}
487    let f = {slot_name_fmt}.get()
488        .expect(concat!("yog: export '", "{name}", "' not bound"));
489    let output = ::yog_api::__interop_call(*f, &input_bytes, "{name}");
490    ::yog_api::rkyv::from_bytes::<{output_type_fmt}, ::yog_api::rkyv::rancor::Error>(&output)
491        .expect(concat!("yog: the result of export '", "{name}", "' didn't decode"))
492}}
493
494static {slot_name_fmt}: ::std::sync::OnceLock<{wrapper_ty}> = ::std::sync::OnceLock::new();
495
496#[doc(hidden)]
497#[unsafe(no_mangle)]
498pub unsafe extern "C" fn {bind_name_fmt}(ptr: *const ::std::os::raw::c_void) {{
499    let f: {wrapper_ty} = unsafe {{ ::std::mem::transmute(ptr) }};
500    {slot_name_fmt}.set(f).ok();
501}}
502
503// Queues the binding when the consuming mod's library loads; `export_mod!`
504// binds it once `{mod_id}` has exported `{name}`.
505#[::yog_api::ctor::ctor(unsafe, crate_path = ::yog_api::ctor)]
506#[doc(hidden)]
507fn __yog_import_ctor_{name}() {{
508    ::yog_api::__yog_import_registry().lock().unwrap().push(::yog_api::YogImportEntry {{
509        mod_id: "{mod_id}",
510        symbol: "{name}",
511        bind_fn: {bind_name_fmt} as *const () as usize,
512    }});
513}}
514"#
515    )
516}
517
518/// Recursively collect every `.rs` file under `dir`, so `#[yog_export]` items
519/// can live in any module/subfolder — not just flat files directly in `src/`.
520pub fn collect_rs_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
521    let mut out = Vec::new();
522    for entry in std::fs::read_dir(dir).map_err(|e| e.to_string())? {
523        let entry = entry.map_err(|e| e.to_string())?;
524        let path = entry.path();
525        if path.is_dir() {
526            out.extend(collect_rs_files(&path)?);
527        } else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
528            out.push(path);
529        }
530    }
531    Ok(out)
532}
533
534/// What the exports crate's `Cargo.toml` says.
535pub struct CrateMeta<'a> {
536    pub name: &'a str,
537    pub version: &'a str,
538    pub edition: &'a str,
539    /// The mod's authors, joined with `", "`.
540    pub authors: &'a str,
541    pub license: &'a str,
542    pub mod_id: &'a str,
543    pub yog_api: &'a str,
544    pub rkyv: &'a str,
545}
546
547/// The exports crate's `Cargo.toml`.
548pub fn cargo_toml(m: &CrateMeta) -> String {
549    let maybe_authors = if m.authors.is_empty() {
550        String::new()
551    } else {
552        format!("authors = [\"{}\"]\n", m.authors.replace(", ", "\", \""))
553    };
554    format!(
555        r#"[package]
556name = "{name}"
557version = "{version}"
558edition = "{edition}"
559{maybe_authors}license = "{license}"
560description = "Exports from the {mod_id} Yog mod — generated by `yog publish exports`."
561
562[dependencies]
563yog-api = "{yog_api}"
564rkyv = "{rkyv}"
565
566[lib]
567crate-type = ["cdylib", "lib"]
568"#,
569        name = m.name,
570        version = m.version,
571        edition = m.edition,
572        license = m.license,
573        mod_id = m.mod_id,
574        yog_api = m.yog_api,
575        rkyv = m.rkyv,
576    )
577}
578
579/// The exports crate's `src/lib.rs`: the imports from [`collect_uses`],
580/// structs and enums with rkyv derives so consumers get the serialisable
581/// types, and an interop wrapper per fn (the same pattern as `import!`).
582pub fn lib_rs(
583    mod_id: &str,
584    version: &str,
585    exports: &[ExportItem],
586    system_uses: &[String],
587    type_uses: &[String],
588) -> String {
589    let mut lib_rs = format!(
590        "//! Auto-generated exports from `{mod_id}` v{version}.\n\
591         //! DO NOT EDIT — generated by `yog publish exports`.\n\n\
592         #![allow(unused_imports)]\n\n"
593    );
594
595    // Add system uses (std, core, alloc)
596    if !system_uses.is_empty() {
597        lib_rs.push_str("// System dependencies\n");
598        for u in system_uses {
599            let _ = writeln!(lib_rs, "use {u};");
600        }
601        lib_rs.push('\n');
602    }
603
604    // Add type dependencies (yog_api, rkyv, etc.)
605    // Filter out crate-internal imports and duplicates of exported items
606    let exported_names: HashSet<&str> = exports.iter().map(|e| e.name.as_str()).collect();
607    if !type_uses.is_empty() {
608        lib_rs.push_str("// Type dependencies\n");
609        for u in type_uses {
610            // Skip imports that:
611            // 1. Reference crate:: (internal modules not available in exports crate)
612            // 2. Are the same as an exported item (would cause duplicate definitions)
613            let first_part = u.split("::").next().unwrap_or("");
614            if LOCAL_ROOTS.contains(&first_part) {
615                continue;
616            }
617            let last_part = u.rsplit("::").next().unwrap_or(u);
618            if exported_names.contains(last_part) {
619                continue;
620            }
621            let _ = writeln!(lib_rs, "use {u};");
622        }
623        lib_rs.push('\n');
624    }
625
626    for item in exports {
627        match item.kind {
628            ExportKind::Struct | ExportKind::Enum => {
629                // Strip existing #[derive(...)] to avoid duplicates.
630                let _ = writeln!(
631                    lib_rs,
632                    "#[derive(::yog_api::rkyv::Archive, ::yog_api::rkyv::Serialize, ::yog_api::rkyv::Deserialize, Debug, Clone, PartialEq)]\n{}",
633                    strip_derive_attrs(&item.source)
634                );
635            }
636            ExportKind::Fn => lib_rs.push_str(&generate_fn_wrapper(mod_id, &item.name, &item.source)),
637        }
638    }
639    // Types are already in crate root, no extra re-export needed.
640    lib_rs
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646
647    const SRC: &str = r#"
648use yog_api::prelude::*;
649
650#[yog_export]
651pub struct Pipe { pub id: u32 }
652
653#[yog_export]
654pub enum Kind { Item, Fluid }
655
656/// Not exported.
657pub fn helper() {}
658
659#[yog_export]
660pub fn send(
661    registry: &mut Registry,
662    pipe: Pipe,
663    amount: u32,
664) -> Option<String> {
665    None
666}
667
668#[yog_export]
669pub fn count() -> u64 { 0 }
670"#;
671
672    #[test]
673    fn scans_every_exported_item_and_nothing_else() {
674        let items = scan(SRC);
675        let names: Vec<(&str, ExportKind)> = items.iter().map(|i| (i.name.as_str(), i.kind)).collect();
676        assert_eq!(names, [("Pipe", ExportKind::Struct), ("Kind", ExportKind::Enum), ("send", ExportKind::Fn), ("count", ExportKind::Fn)]);
677        assert!(!items.iter().any(|i| i.source.contains("#[yog_export]")), "the attribute is stripped");
678        assert!(items[2].source.contains("amount: u32"), "a wrapped signature is whole");
679    }
680
681    #[test]
682    fn wrappers_call_through_one_helper_and_drop_the_registry() {
683        let items = scan(SRC);
684        let w = generate_fn_wrapper("yog-pipes", "send", &items[2].source);
685        assert!(w.contains("pub fn send(pipe: Pipe, amount: u32) -> Option<String>"), "callers see the export's names: {w}");
686        assert!(w.contains("::yog_api::__interop_call("), "{w}");
687        assert!(w.contains("static __YOG_SLOT_SEND"), "{w}");
688        let none = generate_fn_wrapper("yog-pipes", "count", &items[3].source);
689        assert!(none.contains("pub fn count() -> u64"), "{none}");
690    }
691
692    #[test]
693    fn a_doc_comment_with_parentheses_keeps_the_parameters() {
694        let src = "/// Drives `channel` (a pipe or an endpoint); 0 stops.\n#[yog_export]\npub fn emit(\n    capability: String,\n    mut pos: (i32, i32),\n    levels: HashMap<String, u8>,\n) -> Result<(), String> {\n    todo!()\n}\n";
695        let item = &scan(src)[0];
696        assert!(item.source.starts_with("/// Drives"), "{}", item.source);
697        let w = generate_fn_wrapper("yog-pipes", "emit", &item.source);
698        assert!(w.contains("pub fn emit(capability: String, pos: (i32, i32), levels: HashMap<String, u8>) -> Result<(), String>"), "{w}");
699        assert!(w.contains("&(capability, pos, levels)"), "{w}");
700    }
701
702    #[test]
703    fn braces_in_strings_and_comments_dont_end_an_item() {
704        let src = r#"
705mod inner {
706    /// Opens `{` — and a doc comment can say so.
707    #[yog_export]
708    pub fn open(s: String) -> String {
709        // } not the end
710        format!("{{{s}")
711    }
712}
713
714#[yog_export]
715pub struct After { pub x: u8 }
716"#;
717        let items = scan(src);
718        let names: Vec<&str> = items.iter().map(|i| i.name.as_str()).collect();
719        assert_eq!(names, ["open", "After"]);
720        assert!(items[0].source.starts_with("/// Opens"), "{}", items[0].source);
721        assert!(items[0].source.trim_end().ends_with('}') && items[0].source.contains("format!(\"{{{s}\")"), "{}", items[0].source);
722        assert!(!items[0].source.contains("yog_export"));
723    }
724}