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