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 = "::yog_api::YogExportFn".to_string();
575
576    let serialize_block = if tuple_expr.is_empty() {
577        "let input_bytes: Vec<u8> = Vec::new();".to_string()
578    } else {
579        format!(
580            "let aligned = ::yog_api::rkyv::to_bytes::<::yog_api::rkyv::rancor::Error>(\
581                &{tuple_expr}\
582            ).unwrap_or_default();\n            \
583             let input_bytes: Vec<u8> = aligned.to_vec();"
584        )
585    };
586
587    format!(
588        r#"#[allow(non_snake_case)]
589pub fn {name}({params_sig_fmt}) -> {output_type_fmt} {{
590    {serialize_block}
591    let f = {slot_name_fmt}.get()
592        .expect(concat!("yog: export '", "{name}", "' not bound"));
593    let output = ::yog_api::__interop_call(*f, &input_bytes, "{name}");
594    ::yog_api::rkyv::from_bytes::<{output_type_fmt}, ::yog_api::rkyv::rancor::Error>(&output)
595        .expect(concat!("yog: the result of export '", "{name}", "' didn't decode"))
596}}
597
598static {slot_name_fmt}: ::std::sync::OnceLock<{wrapper_ty}> = ::std::sync::OnceLock::new();
599
600#[doc(hidden)]
601#[unsafe(no_mangle)]
602pub unsafe extern "C" fn {bind_name_fmt}(ptr: *const ::std::os::raw::c_void) {{
603    let f: {wrapper_ty} = unsafe {{ ::std::mem::transmute(ptr) }};
604    {slot_name_fmt}.set(f).ok();
605}}
606
607// Queues the binding when the consuming mod's library loads; `export_mod!`
608// binds it once `{mod_id}` has exported `{name}`.
609#[::yog_api::ctor::ctor(unsafe, crate_path = ::yog_api::ctor)]
610#[doc(hidden)]
611fn __yog_import_ctor_{name}() {{
612    ::yog_api::__yog_import_registry().lock().unwrap().push(::yog_api::YogImportEntry {{
613        mod_id: "{mod_id}",
614        symbol: "{name}",
615        bind_fn: {bind_name_fmt} as *const () as usize,
616    }});
617}}
618"#
619    )
620}
621
622/// Recursively collect every `.rs` file under `dir`, so `#[yog_export]` items
623/// can live in any module/subfolder — not just flat files directly in `src/`.
624pub fn collect_rs_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
625    let mut out = Vec::new();
626    for entry in std::fs::read_dir(dir).map_err(|e| e.to_string())? {
627        let entry = entry.map_err(|e| e.to_string())?;
628        let path = entry.path();
629        if path.is_dir() {
630            out.extend(collect_rs_files(&path)?);
631        } else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
632            out.push(path);
633        }
634    }
635    Ok(out)
636}
637
638/// What the exports crate's `Cargo.toml` says.
639pub struct CrateMeta<'a> {
640    pub name: &'a str,
641    pub version: &'a str,
642    pub edition: &'a str,
643    /// The mod's authors, joined with `", "`.
644    pub authors: &'a str,
645    pub license: &'a str,
646    pub mod_id: &'a str,
647    pub yog_api: &'a str,
648    pub rkyv: &'a str,
649}
650
651/// The exports crate's `Cargo.toml`.
652pub fn cargo_toml(m: &CrateMeta) -> String {
653    let maybe_authors = if m.authors.is_empty() {
654        String::new()
655    } else {
656        format!("authors = [\"{}\"]\n", m.authors.replace(", ", "\", \""))
657    };
658    format!(
659        r#"[package]
660name = "{name}"
661version = "{version}"
662edition = "{edition}"
663{maybe_authors}license = "{license}"
664description = "Exports from the {mod_id} Yog mod — generated by `yog publish exports`."
665
666[dependencies]
667yog-api = "{yog_api}"
668rkyv = "{rkyv}"
669
670[lib]
671crate-type = ["cdylib", "lib"]
672"#,
673        name = m.name,
674        version = m.version,
675        edition = m.edition,
676        license = m.license,
677        mod_id = m.mod_id,
678        yog_api = m.yog_api,
679        rkyv = m.rkyv,
680    )
681}
682
683/// The exports crate's `src/lib.rs`: the imports from [`collect_uses`],
684/// structs and enums with rkyv derives so consumers get the serialisable
685/// types, and an interop wrapper per fn (the same pattern as `import!`).
686pub fn lib_rs(
687    mod_id: &str,
688    version: &str,
689    exports: &[ExportItem],
690    system_uses: &[String],
691    type_uses: &[String],
692) -> String {
693    let mut lib_rs = format!(
694        "//! Auto-generated exports from `{mod_id}` v{version}.\n\
695         //! DO NOT EDIT — generated by `yog publish exports`.\n\n\
696         #![allow(unused_imports)]\n\n"
697    );
698
699    // Add system uses (std, core, alloc)
700    if !system_uses.is_empty() {
701        lib_rs.push_str("// System dependencies\n");
702        for u in system_uses {
703            let _ = writeln!(lib_rs, "use {u};");
704        }
705        lib_rs.push('\n');
706    }
707
708    // Add type dependencies (yog_api, rkyv, etc.)
709    // Filter out crate-internal imports and duplicates of exported items
710    let exported_names: HashSet<&str> = exports.iter().map(|e| e.name.as_str()).collect();
711    if !type_uses.is_empty() {
712        lib_rs.push_str("// Type dependencies\n");
713        for u in type_uses {
714            // Skip imports that:
715            // 1. Reference crate:: (internal modules not available in exports crate)
716            // 2. Are the same as an exported item (would cause duplicate definitions)
717            let first_part = u.split("::").next().unwrap_or("");
718            if LOCAL_ROOTS.contains(&first_part) {
719                continue;
720            }
721            let last_part = u.rsplit("::").next().unwrap_or(u);
722            if exported_names.contains(last_part) {
723                continue;
724            }
725            let _ = writeln!(lib_rs, "use {u};");
726        }
727        lib_rs.push('\n');
728    }
729
730    for item in exports {
731        match item.kind {
732            ExportKind::Struct | ExportKind::Enum => {
733                // Strip existing #[derive(...)] to avoid duplicates.
734                let _ = writeln!(
735                    lib_rs,
736                    "#[derive(::yog_api::rkyv::Archive, ::yog_api::rkyv::Serialize, ::yog_api::rkyv::Deserialize, Debug, Clone, PartialEq)]\n{}",
737                    strip_derive_attrs(&item.source)
738                );
739            }
740            ExportKind::Fn => lib_rs.push_str(&generate_fn_wrapper(mod_id, &item.name, &item.source)),
741        }
742    }
743    // Types are already in crate root, no extra re-export needed.
744    lib_rs
745}