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