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    let arg_names: Vec<String> = (0..typed_params.len())
505        .map(|i| format!("__yog_arg{i}"))
506        .collect();
507    let data_types: Vec<&str> = typed_params.iter().map(|(_, ty)| ty.as_str()).collect();
508
509    let params_sig = arg_names
510        .iter()
511        .zip(data_types.iter())
512        .map(|(n, t)| format!("{n}: {t}"))
513        .collect::<Vec<_>>()
514        .join(", ");
515
516    let tuple_expr = match arg_names.len() {
517        0 => String::new(),
518        1 => arg_names[0].clone(),
519        _ => format!("({})", arg_names.join(", ")),
520    };
521
522    // Extract return type.
523    // Find `->` AFTER the closing paren of parameters (paren_end).
524    let output_type = match paren_end {
525        Some(pe) => {
526            let arrow_rel = if pe < sig_line.len() {
527                sig_line[pe..].find("->")
528            } else {
529                None
530            };
531            match arrow_rel {
532                Some(rel) => {
533                    let arrow = pe + rel;
534                    let rest: &str = sig_line[arrow + 2..].trim();
535                    // Take until `{` or `;` handling angle bracket balance
536                    let mut depth: i32 = 0;
537                    let mut end = 0;
538                    for (i, ch) in rest.char_indices() {
539                        match ch {
540                            '<' => depth += 1,
541                            '>' => depth -= 1,
542                            '{' | ';' => break,
543                            _ => {}
544                        }
545                        if depth < 0 {
546                            break;
547                        }
548                        if ch == '=' {
549                            break;
550                        }
551                        end = i + ch.len_utf8();
552                    }
553                    rest[..end].trim().to_string()
554                }
555                None => "()".to_string(),
556            }
557        }
558        None => "()".to_string(),
559    };
560
561    // Statics use SCREAMING_SNAKE_CASE per Rust convention; the bind fn stays
562    // snake_case since it's a function, not a static.
563    let slot_name = format!("__YOG_SLOT_{}", name.to_uppercase());
564    let bind_name = format!("__yog_bind_{}", name);
565
566    // Escape { and } for use in format! — types like `Result<(), String>` contain
567    // curly braces that would be interpreted as format specifiers.
568    let output_type_fmt = output_type.replace('{', "{{").replace('}', "}}");
569    let params_sig_fmt = params_sig.replace('{', "{{").replace('}', "}}");
570    let slot_name_fmt = slot_name.replace('{', "{{").replace('}', "}}");
571    let bind_name_fmt = bind_name.replace('{', "{{").replace('}', "}}");
572
573    // Build the C-ABI function pointer type
574    let wrapper_ty = "unsafe extern \"C\" fn(input_ptr: *const u8, input_len: u32, \
575         out_data: *mut *mut u8, out_len: *mut u32, out_cap: *mut u32)"
576        .to_string();
577
578    let serialize_block = if tuple_expr.is_empty() {
579        "let input_bytes: Vec<u8> = Vec::new();".to_string()
580    } else {
581        format!(
582            "let aligned = ::yog_api::rkyv::to_bytes::<::yog_api::rkyv::rancor::Error>(\
583                &{tuple_expr}\
584            ).unwrap_or_default();\n            \
585             let input_bytes: Vec<u8> = aligned.to_vec();"
586        )
587    };
588
589    format!(
590        r#"#[allow(non_snake_case)]
591pub fn {name}({params_sig_fmt}) -> {output_type_fmt} {{
592    {serialize_block}
593    let f = {slot_name_fmt}.get()
594        .expect(concat!("yog: export '", "{name}", "' not bound"));
595    let mut out_data: *mut u8 = std::ptr::null_mut();
596    let mut out_len: u32 = 0;
597    let mut out_cap: u32 = 0;
598    unsafe {{
599        f(input_bytes.as_ptr(), input_bytes.len() as u32,
600          &mut out_data, &mut out_len, &mut out_cap);
601        let output_slice = std::slice::from_raw_parts(out_data, out_len as usize);
602        let result: {output_type_fmt} = ::yog_api::rkyv::from_bytes::<_, ::yog_api::rkyv::rancor::Error>(output_slice)
603            .expect(concat!("yog: deser failed in export '", "{name}", "'"));
604        let _ = Vec::from_raw_parts(out_data, out_len as usize, out_cap as usize);
605        result
606    }}
607}}
608
609static {slot_name_fmt}: ::std::sync::OnceLock<{wrapper_ty}> = ::std::sync::OnceLock::new();
610
611#[doc(hidden)]
612#[unsafe(no_mangle)]
613pub unsafe extern "C" fn {bind_name_fmt}(ptr: *const ::std::os::raw::c_void) {{
614    let f: {wrapper_ty} = unsafe {{ ::std::mem::transmute(ptr) }};
615    {slot_name_fmt}.set(f).ok();
616}}
617
618// Queues the binding when the consuming mod's library loads; `export_mod!`
619// binds it once `{mod_id}` has exported `{name}`.
620#[::yog_api::ctor::ctor(unsafe, crate_path = ::yog_api::ctor)]
621#[doc(hidden)]
622fn __yog_import_ctor_{name}() {{
623    ::yog_api::__yog_import_registry().lock().unwrap().push(::yog_api::YogImportEntry {{
624        mod_id: "{mod_id}",
625        symbol: "{name}",
626        bind_fn: {bind_name_fmt} as *const () as usize,
627    }});
628}}
629"#
630    )
631}
632
633/// Recursively collect every `.rs` file under `dir`, so `#[yog_export]` items
634/// can live in any module/subfolder — not just flat files directly in `src/`.
635pub fn collect_rs_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
636    let mut out = Vec::new();
637    for entry in std::fs::read_dir(dir).map_err(|e| e.to_string())? {
638        let entry = entry.map_err(|e| e.to_string())?;
639        let path = entry.path();
640        if path.is_dir() {
641            out.extend(collect_rs_files(&path)?);
642        } else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
643            out.push(path);
644        }
645    }
646    Ok(out)
647}
648
649/// What the exports crate's `Cargo.toml` says.
650pub struct CrateMeta<'a> {
651    pub name: &'a str,
652    pub version: &'a str,
653    pub edition: &'a str,
654    /// The mod's authors, joined with `", "`.
655    pub authors: &'a str,
656    pub license: &'a str,
657    pub mod_id: &'a str,
658    pub yog_api: &'a str,
659    pub rkyv: &'a str,
660}
661
662/// The exports crate's `Cargo.toml`.
663pub fn cargo_toml(m: &CrateMeta) -> String {
664    let maybe_authors = if m.authors.is_empty() {
665        String::new()
666    } else {
667        format!("authors = [\"{}\"]\n", m.authors.replace(", ", "\", \""))
668    };
669    format!(
670        r#"[package]
671name = "{name}"
672version = "{version}"
673edition = "{edition}"
674{maybe_authors}license = "{license}"
675description = "Exports from the {mod_id} Yog mod — generated by `yog publish exports`."
676
677[dependencies]
678yog-api = "{yog_api}"
679rkyv = "{rkyv}"
680
681[lib]
682crate-type = ["cdylib", "lib"]
683"#,
684        name = m.name,
685        version = m.version,
686        edition = m.edition,
687        license = m.license,
688        mod_id = m.mod_id,
689        yog_api = m.yog_api,
690        rkyv = m.rkyv,
691    )
692}
693
694/// The exports crate's `src/lib.rs`: the imports from [`collect_uses`],
695/// structs and enums with rkyv derives so consumers get the serialisable
696/// types, and an interop wrapper per fn (the same pattern as `import!`).
697pub fn lib_rs(
698    mod_id: &str,
699    version: &str,
700    exports: &[ExportItem],
701    system_uses: &[String],
702    type_uses: &[String],
703) -> String {
704    let mut lib_rs = format!(
705        "//! Auto-generated exports from `{mod_id}` v{version}.\n\
706         //! DO NOT EDIT — generated by `yog publish exports`.\n\n\
707         #![allow(unused_imports)]\n\n"
708    );
709
710    // Add system uses (std, core, alloc)
711    if !system_uses.is_empty() {
712        lib_rs.push_str("// System dependencies\n");
713        for u in system_uses {
714            let _ = writeln!(lib_rs, "use {u};");
715        }
716        lib_rs.push('\n');
717    }
718
719    // Add type dependencies (yog_api, rkyv, etc.)
720    // Filter out crate-internal imports and duplicates of exported items
721    let exported_names: HashSet<&str> = exports.iter().map(|e| e.name.as_str()).collect();
722    if !type_uses.is_empty() {
723        lib_rs.push_str("// Type dependencies\n");
724        for u in type_uses {
725            // Skip imports that:
726            // 1. Reference crate:: (internal modules not available in exports crate)
727            // 2. Are the same as an exported item (would cause duplicate definitions)
728            let first_part = u.split("::").next().unwrap_or("");
729            if LOCAL_ROOTS.contains(&first_part) {
730                continue;
731            }
732            let last_part = u.rsplit("::").next().unwrap_or(u);
733            if exported_names.contains(last_part) {
734                continue;
735            }
736            let _ = writeln!(lib_rs, "use {u};");
737        }
738        lib_rs.push('\n');
739    }
740
741    for item in exports {
742        match item.kind {
743            ExportKind::Struct | ExportKind::Enum => {
744                // Strip existing #[derive(...)] to avoid duplicates.
745                let _ = writeln!(
746                    lib_rs,
747                    "#[derive(::yog_api::rkyv::Archive, ::yog_api::rkyv::Serialize, ::yog_api::rkyv::Deserialize, Debug, Clone, PartialEq)]\n{}",
748                    strip_derive_attrs(&item.source)
749                );
750            }
751            ExportKind::Fn => lib_rs.push_str(&generate_fn_wrapper(mod_id, &item.name, &item.source)),
752        }
753    }
754    // Types are already in crate root, no extra re-export needed.
755    lib_rs
756}