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