Skip to main content

yog_cli/project/
exports.rs

1//! `yog publish exports` — the `-exports` crate generated from a mod's
2//! `#[yog_export]` items.
3
4use std::collections::{BTreeMap, BTreeSet, HashSet};
5use std::fmt::Write as _;
6use std::path::{Path, PathBuf};
7
8use yog_codegen::{Header, Regenerate};
9
10/// Mod `mod_id`'s exports crate's name, as it's published to crates.io:
11/// `my-mod` → `my_mod_exports`.
12pub fn crate_name(mod_id: &str) -> String {
13    format!("{}{EXPORTS_SUFFIX}", yog_ids::rust_name(mod_id))
14}
15
16/// What an exports crate's name adds to its mod's.
17const EXPORTS_SUFFIX: &str = "_exports";
18
19/// What a `#[yog_export]` item is.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum ExportKind {
22    /// `pub struct`: shared as a type.
23    Struct,
24    /// `pub enum`: shared as a type.
25    Enum,
26    /// `pub fn`: called through the bus.
27    Fn,
28}
29
30/// Item found by the export scanner.
31#[derive(Debug)]
32pub struct ExportItem {
33    /// What it is.
34    pub kind: ExportKind,
35    pub(crate) name: String,
36    /// Full Rust source of the item (without `#[yog_export]`).
37    pub source: String,
38}
39
40/// Every `#[yog_export]` item of one source file (inline modules too),
41/// read with `syn`: its exact text — comments, layout — without the
42/// `#[yog_export]` attribute itself. A file that doesn't parse has none
43/// (the build reports it).
44pub fn scan(src: &str) -> Vec<ExportItem> {
45    let Ok(file) = syn::parse_file(src) else {
46        return Vec::new();
47    };
48    let mut out = Vec::new();
49    collect(&file.items, src, &mut out);
50    out
51}
52
53fn collect(items: &[syn::Item], src: &str, out: &mut Vec<ExportItem>) {
54    use syn::spanned::Spanned;
55    for item in items {
56        let (attrs, vis, kind, name) = match item {
57            syn::Item::Struct(s) => (&s.attrs, &s.vis, ExportKind::Struct, s.ident.to_string()),
58            syn::Item::Enum(e) => (&e.attrs, &e.vis, ExportKind::Enum, e.ident.to_string()),
59            syn::Item::Fn(f) => (&f.attrs, &f.vis, ExportKind::Fn, f.sig.ident.to_string()),
60            syn::Item::Mod(m) => {
61                if let Some((_, inner)) = &m.content {
62                    collect(inner, src, out);
63                }
64                continue;
65            }
66            _ => continue,
67        };
68        let is_export = |a: &syn::Attribute| {
69            a.path()
70                .segments
71                .last()
72                .is_some_and(|s| s.ident == "yog_export")
73        };
74        if !attrs.iter().any(is_export) || !matches!(vis, syn::Visibility::Public(_)) {
75            continue;
76        }
77        let whole = item.span().byte_range();
78        // The item's text with each `#[yog_export]` (and the line it leaves empty) cut out.
79        let mut text = String::new();
80        let mut at = whole.start;
81        for a in attrs.iter().filter(|a| is_export(a)) {
82            let r = a.span().byte_range();
83            text.push_str(&src[at..r.start]);
84            at = r.end;
85            let rest = &src[at..whole.end];
86            let blank = rest.len() - rest.trim_start_matches([' ', '\t']).len();
87            if rest[blank..].starts_with('\n') {
88                at += blank + 1;
89            }
90        }
91        text.push_str(&src[at..whole.end]);
92        // Indentation of nested items stays as written; the text starts at the item.
93        let source = format!("{}\n", text.trim_start());
94        out.push(ExportItem { kind, name, source });
95    }
96}
97
98/// Names always in scope — primitives and the std/core/alloc prelude and
99/// modules — that never need an import.
100const PRIMITIVES: &[&str] = &[
101    "bool",
102    "u8",
103    "u16",
104    "u32",
105    "u64",
106    "u128",
107    "usize",
108    "i8",
109    "i16",
110    "i32",
111    "i64",
112    "i128",
113    "isize",
114    "f32",
115    "f64",
116    "char",
117    "str",
118    "String",
119    "Vec",
120    "Option",
121    "Result",
122    "Box",
123    "Rc",
124    "Arc",
125    "Cow",
126    "PhantomData",
127    "Ordering",
128    "Self",
129    "()",
130    "HashMap",
131    "HashSet",
132    "BTreeMap",
133    "BTreeSet",
134    "LinkedList",
135    "VecDeque",
136    "BinaryHeap",
137    "Path",
138    "PathBuf",
139    "Duration",
140    "Instant",
141    "SystemTime",
142    "Error",
143    "fmt",
144    "ops",
145    "cmp",
146    "iter",
147    "slice",
148    "cell",
149    "sync",
150    "collections",
151    "convert",
152    "marker",
153    "mem",
154    "ptr",
155    "hint",
156    "borrow",
157    "hash",
158    "default",
159    "num",
160    "array",
161    "range",
162    "std::",
163    "core::",
164];
165
166/// Paths into std/core/alloc — always available, imported as "system" uses.
167const SYSTEM_ROOTS: [&str; 3] = ["std::", "core::", "alloc::"];
168
169/// Path roots internal to the mod crate, meaningless in the exports crate.
170const LOCAL_ROOTS: [&str; 3] = ["crate", "super", "self"];
171
172fn is_system(path: &str) -> bool {
173    SYSTEM_ROOTS.iter().any(|p| path.starts_with(p))
174}
175
176/// What a path's segments are joined with.
177const PATH_SEP: &str = "::";
178/// What a `use` line starts with.
179const USE: &str = "use ";
180/// What a glob import ends with.
181const GLOB: &str = "*";
182
183/// The imports the exports crate needs: std/core/alloc ones, and the rest.
184#[derive(Default)]
185struct Imports {
186    system: HashSet<String>,
187    types: HashSet<String>,
188}
189
190impl Imports {
191    fn add(&mut self, path: String) {
192        if is_system(&path) {
193            self.system.insert(path);
194        } else {
195            self.types.insert(path);
196        }
197    }
198}
199
200/// The paths of the mod's own `use` lines (`yog_api::registry::Mod`,
201/// `foo::{a, b}`, `foo::*`); a broken one (`a::{` alone) left out.
202fn mod_uses(sources: &[String]) -> Vec<String> {
203    sources
204        .iter()
205        .flat_map(|src| src.lines())
206        .filter_map(|line| line.trim().strip_prefix(USE))
207        .map(|p| p.trim_end_matches(';').trim())
208        .filter(|p| !p.is_empty() && !(p.contains('{') && !p.contains('}')))
209        .map(str::to_string)
210        .collect()
211}
212
213/// What a name in an export resolves to, as `rustc` would find it.
214struct Resolver<'a> {
215    uses: &'a [String],
216    /// The exported items' names: defined in the crate itself.
217    local: HashSet<&'a str>,
218}
219
220impl Resolver<'_> {
221    /// The `use` path bringing `name` into scope — the shortest (most
222    /// specific) whose last segment is it, or a glob; none for a
223    /// primitive, a std name or one the crate defines.
224    fn resolve(&self, name: &str) -> Option<String> {
225        if PRIMITIVES.contains(&name) || is_system(name) || self.local.contains(name) {
226            return None;
227        }
228        self.uses
229            .iter()
230            .filter(
231                |p| matches!(p.rsplit(PATH_SEP).next(), Some(last) if last == name || last == GLOB),
232            )
233            .min_by_key(|p| p.len())
234            .cloned()
235    }
236}
237
238/// The path starting at `slice` (`::foo::Bar`, `yog_api::x::Y<T>`): up to
239/// what ends it outside angle brackets.
240fn path_at(slice: &str) -> &str {
241    let mut depth: i32 = 0;
242    let mut end = 0;
243    for (i, ch) in slice.char_indices() {
244        match ch {
245            '<' => depth += 1,
246            '>' => depth -= 1,
247            ' ' | ',' | ';' | '{' | '}' | '(' | ')' | ':' | '[' | ']' | '.' if depth == 0 => {
248                end = i;
249                break;
250            }
251            _ => {}
252        }
253        if depth < 0 {
254            end = i;
255            break;
256        }
257    }
258    slice[..end].trim().trim_end_matches(PATH_SEP)
259}
260
261/// The written-out paths an export uses: std ones, the API's and the mod's own.
262fn written_paths(body: &str, imports: &mut Imports) {
263    let api = yog_ids::rust_name(yog_ids::rust_crate::API);
264    for (start, _) in body.match_indices(PATH_SEP) {
265        let path = path_at(&body[start..]);
266        if path.is_empty() {
267            continue;
268        }
269        let first = path
270            .trim_start_matches(':')
271            .split(PATH_SEP)
272            .next()
273            .unwrap_or("");
274        if is_system(path) || first == api || LOCAL_ROOTS.contains(&first) {
275            imports.add(path.to_string());
276        }
277    }
278}
279
280/// The bare type names an export uses (CamelCase), each by its import.
281fn bare_types(body: &str, resolver: &Resolver, imports: &mut Imports) {
282    let words = body
283        .split(|c: char| !c.is_alphanumeric() && c != '_')
284        .filter(|s| !s.is_empty());
285    for word in words {
286        if word.starts_with(char::is_uppercase)
287            && let Some(p) = resolver.resolve(word)
288        {
289            imports.add(p);
290        }
291    }
292}
293
294/// Collect the imports a generated exports crate needs.
295///
296/// This mirrors how `rustc` resolves names:
297///   1. Is the name a primitive / in `std` / `core` / `alloc` (always in scope)? → no import.
298///   2. Is the name defined in this same crate (an exported struct/enum/fn)? → no import.
299///   3. Otherwise, does the mod's own `use` statements bring it into scope?
300///      → emit the matching `use` path (preferring the most specific one).
301///   4. Paths written out in full (`yog_api::…`, `std::…`) come as they are.
302///
303/// Returns `(system_uses, type_uses)` where:
304/// - `system_uses`: std/core/alloc `use` paths like `std::collections::HashMap`
305/// - `type_uses`: external `use` paths like `yog_api::registry::Mod`
306pub fn collect_uses(exports: &[ExportItem], sources: &[String]) -> (Vec<String>, Vec<String>) {
307    let uses = mod_uses(sources);
308    let mut imports = Imports::default();
309    for u in &uses {
310        imports.add(u.clone());
311    }
312    let resolver = Resolver {
313        uses: &uses,
314        local: exports.iter().map(|e| e.name.as_str()).collect(),
315    };
316    for item in exports {
317        written_paths(&item.source, &mut imports);
318        bare_types(&item.source, &resolver, &mut imports);
319    }
320    (normalize(imports.system), normalize(imports.types))
321}
322
323/// Every `a::b::{X, Y}` / `a::b::X` merged into one import per module
324/// path, so a name pulled in by several files is imported once; sorted.
325fn normalize(paths: HashSet<String>) -> Vec<String> {
326    let mut groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
327    let mut nested: Vec<String> = Vec::new();
328    for p in paths {
329        let p = p.trim().to_string();
330        if let Some((prefix, rest)) = p.split_once("::{") {
331            let inside = rest.strip_suffix('}').unwrap_or(rest);
332            if inside.contains('{') {
333                nested.push(p);
334                continue;
335            }
336            let names = inside
337                .split(',')
338                .map(str::trim)
339                .filter(|s| !s.is_empty())
340                .map(str::to_string);
341            groups.entry(prefix.to_string()).or_default().extend(names);
342        } else if let Some((prefix, item)) = p.rsplit_once(PATH_SEP) {
343            groups
344                .entry(prefix.to_string())
345                .or_default()
346                .insert(item.to_string());
347        } else {
348            nested.push(p);
349        }
350    }
351    let mut keep: Vec<String> = groups
352        .into_iter()
353        .map(|(prefix, names)| grouped(&prefix, names))
354        .collect();
355    nested.sort();
356    nested.dedup();
357    keep.extend(nested);
358    keep.sort();
359    keep
360}
361
362/// `prefix::Name`, or `prefix::{A, B}` for several.
363fn grouped(prefix: &str, names: BTreeSet<String>) -> String {
364    let names: Vec<String> = names.into_iter().collect();
365    match names.as_slice() {
366        [one] => format!("{prefix}{PATH_SEP}{one}"),
367        _ => format!("{prefix}{PATH_SEP}{{{}}}", names.join(", ")),
368    }
369}
370
371/// Strip `#[derive(...)]` lines from a Rust source item to avoid duplicates
372/// when the generator adds its own derives.
373pub(crate) fn strip_derive_attrs(source: &str) -> String {
374    source
375        .lines()
376        .filter(|line| !line.trim().starts_with("#[derive("))
377        .collect::<Vec<_>>()
378        .join("\n")
379}
380
381/// A `#[yog_export]` function's signature as an `import!` item: `pub fn
382/// name(params) -> Out;` — the parameters as the export names them, the
383/// export's own `Registry` left to `import!`. Nothing if it doesn't parse.
384pub fn import_signature(source: &str) -> Option<String> {
385    use syn::spanned::Spanned;
386    let f = syn::parse_str::<syn::ItemFn>(source).ok()?;
387    let sig = source.get(f.sig.span().byte_range())?;
388    // One line: a wrapped signature's line breaks become spaces.
389    Some(format!(
390        "pub {};",
391        sig.split_whitespace().collect::<Vec<_>>().join(" ")
392    ))
393}
394
395/// Recursively collect every `.rs` file under `dir`, so `#[yog_export]` items
396/// can live in any module/subfolder — not just flat files directly in `src/`.
397pub fn collect_rs_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
398    let mut out = Vec::new();
399    for entry in std::fs::read_dir(dir).map_err(|e| e.to_string())? {
400        let entry = entry.map_err(|e| e.to_string())?;
401        let path = entry.path();
402        if path.is_dir() {
403            out.extend(collect_rs_files(&path)?);
404        } else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
405            out.push(path);
406        }
407    }
408    Ok(out)
409}
410
411/// What the exports crate's `Cargo.toml` says.
412pub struct CrateMeta<'a> {
413    /// The crate's name (`<mod>-exports`), version and Rust edition.
414    pub name: &'a str,
415    /// See [`CrateMeta::name`].
416    pub version: &'a str,
417    /// See [`CrateMeta::name`].
418    pub edition: &'a str,
419    /// The mod's authors, joined with `", "`.
420    pub authors: &'a str,
421    /// Its license.
422    pub license: &'a str,
423    /// The mod it calls into.
424    pub mod_id: &'a str,
425    /// The `yog-api` version it builds with.
426    pub yog_api: &'a str,
427}
428
429/// The exports crate's `Cargo.toml`.
430pub fn cargo_toml(m: &CrateMeta) -> String {
431    let maybe_authors = if m.authors.is_empty() {
432        String::new()
433    } else {
434        format!("authors = [\"{}\"]\n", m.authors.replace(", ", "\", \""))
435    };
436    format!(
437        r#"[package]
438name = "{name}"
439version = "{version}"
440edition = "{edition}"
441{maybe_authors}license = "{license}"
442description = "Exports from the {mod_id} Yog mod — generated by `yog publish exports`."
443
444[dependencies]
445yog-api = "{yog_api}"
446
447[lib]
448crate-type = ["cdylib", "lib"]
449"#,
450        name = m.name,
451        version = m.version,
452        edition = m.edition,
453        license = m.license,
454        mod_id = m.mod_id,
455        yog_api = m.yog_api,
456    )
457}
458
459/// The exports crate's `src/lib.rs`: the imports from [`collect_uses`],
460/// the structs and enums (`#[yog_export]`: their schemas), and the
461/// functions as `import!` items — calls of the mod's services.
462pub fn lib_rs(
463    mod_id: &str,
464    version: &str,
465    exports: &[ExportItem],
466    system_uses: &[String],
467    type_uses: &[String],
468) -> String {
469    let header = Header {
470        tool: "`yog publish exports`",
471        source: &format!("`{mod_id}` v{version}"),
472        regenerate: Regenerate::Command("yog publish exports"),
473    };
474    let mut lib_rs = format!(
475        "//! The exports of `{mod_id}` v{version}.\n{}\n\n#![allow(unused_imports)]\n\n",
476        yog_codegen_rust::Rust::inner_doc(&header.text())
477    );
478
479    // Add system uses (std, core, alloc)
480    if !system_uses.is_empty() {
481        lib_rs.push_str("// System dependencies\n");
482        for u in system_uses {
483            let _ = writeln!(lib_rs, "use {u};");
484        }
485        lib_rs.push('\n');
486    }
487
488    // Add type dependencies (yog_api, rkyv, etc.)
489    // Filter out crate-internal imports and duplicates of exported items
490    let exported_names: HashSet<&str> = exports.iter().map(|e| e.name.as_str()).collect();
491    if !type_uses.is_empty() {
492        lib_rs.push_str("// Type dependencies\n");
493        for u in type_uses {
494            // Skip imports that:
495            // 1. Reference crate:: (internal modules not available in exports crate)
496            // 2. Are the same as an exported item (would cause duplicate definitions)
497            let first_part = u.split("::").next().unwrap_or("");
498            if LOCAL_ROOTS.contains(&first_part) {
499                continue;
500            }
501            let last_part = u.rsplit("::").next().unwrap_or(u);
502            if exported_names.contains(last_part) {
503                continue;
504            }
505            let _ = writeln!(lib_rs, "use {u};");
506        }
507        lib_rs.push('\n');
508    }
509
510    // Types: with the schema their values cross with (`#[yog_export]`).
511    for item in exports.iter().filter(|i| i.kind != ExportKind::Fn) {
512        let _ = writeln!(
513            lib_rs,
514            "#[::yog_api::yog_export]\n#[derive(Debug, Clone, PartialEq)]\n{}\n",
515            strip_derive_attrs(&item.source)
516        );
517    }
518    // Functions: calls of `{mod_id}:name` on the bus (`import!`).
519    let fns: Vec<String> = exports
520        .iter()
521        .filter(|i| i.kind == ExportKind::Fn)
522        .filter_map(|i| import_signature(&i.source))
523        .collect();
524    if !fns.is_empty() {
525        let module = yog_ids::rust_name(mod_id);
526        let _ = writeln!(
527            lib_rs,
528            "::yog_api::import! {{\n    from {mod_id:?} {{\n        {}\n    }}\n}}\npub use {module}::*;",
529            fns.join("\n        ")
530        );
531    }
532    lib_rs
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538
539    const SRC: &str = r#"
540use yog_api::prelude::*;
541use yog_codegen::{Header, Regenerate};
542
543#[yog_export]
544pub struct Pipe { pub id: u32 }
545
546#[yog_export]
547pub enum Kind { Item, Fluid }
548
549/// Not exported.
550pub fn helper() {}
551
552#[yog_export]
553pub fn send(
554    registry: &mut Registry,
555    pipe: Pipe,
556    amount: u32,
557) -> Option<String> {
558    None
559}
560
561#[yog_export]
562pub fn count() -> u64 { 0 }
563"#;
564
565    #[test]
566    fn scans_every_exported_item_and_nothing_else() {
567        let items = scan(SRC);
568        let names: Vec<(&str, ExportKind)> =
569            items.iter().map(|i| (i.name.as_str(), i.kind)).collect();
570        assert_eq!(
571            names,
572            [
573                ("Pipe", ExportKind::Struct),
574                ("Kind", ExportKind::Enum),
575                ("send", ExportKind::Fn),
576                ("count", ExportKind::Fn)
577            ]
578        );
579        assert!(
580            !items.iter().any(|i| i.source.contains("#[yog_export]")),
581            "the attribute is stripped"
582        );
583        assert!(
584            items[2].source.contains("amount: u32"),
585            "a wrapped signature is whole"
586        );
587    }
588
589    #[test]
590    fn functions_become_import_items_and_types_exports() {
591        let items = scan(SRC);
592        let lib = lib_rs("yog-pipes", "1.0.0", &items, &[], &[]);
593        assert!(
594            lib.contains("::yog_api::import! {\n    from \"yog-pipes\""),
595            "{lib}"
596        );
597        assert!(lib.contains("pub fn send( registry: &mut Registry, pipe: Pipe, amount: u32, ) -> Option<String>;"), "{lib}");
598        assert!(lib.contains("pub use yog_pipes::*;"), "{lib}");
599        assert!(
600            lib.contains(
601                "#[::yog_api::yog_export]\n#[derive(Debug, Clone, PartialEq)]\npub struct Pipe"
602            ),
603            "{lib}"
604        );
605    }
606
607    #[test]
608    fn a_doc_comment_with_parentheses_keeps_the_parameters() {
609        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";
610        let item = &scan(src)[0];
611        assert!(item.source.starts_with("/// Drives"), "{}", item.source);
612        let sig = import_signature(&item.source).expect("parses");
613        assert!(
614            sig.contains("capability: String, mut pos: (i32, i32), levels: HashMap<String, u8>,"),
615            "{sig}"
616        );
617    }
618
619    #[test]
620    fn braces_in_strings_and_comments_dont_end_an_item() {
621        let src = r#"
622mod inner {
623    /// Opens `{` — and a doc comment can say so.
624    #[yog_export]
625    pub fn open(s: String) -> String {
626        // } not the end
627        format!("{{{s}")
628    }
629}
630
631#[yog_export]
632pub struct After { pub x: u8 }
633"#;
634        let items = scan(src);
635        let names: Vec<&str> = items.iter().map(|i| i.name.as_str()).collect();
636        assert_eq!(names, ["open", "After"]);
637        assert!(
638            items[0].source.starts_with("/// Opens"),
639            "{}",
640            items[0].source
641        );
642        assert!(
643            items[0].source.trim_end().ends_with('}')
644                && items[0].source.contains("format!(\"{{{s}\")"),
645            "{}",
646            items[0].source
647        );
648        assert!(!items[0].source.contains("yog_export"));
649    }
650}