1use std::collections::{BTreeMap, BTreeSet, HashSet};
5use std::fmt::Write as _;
6use std::path::{Path, PathBuf};
7
8use yog_codegen::{Header, Regenerate};
9
10pub fn crate_name(mod_id: &str) -> String {
13 format!("{}{EXPORTS_SUFFIX}", yog_ids::rust_name(mod_id))
14}
15
16const EXPORTS_SUFFIX: &str = "_exports";
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum ExportKind {
22 Struct,
24 Enum,
26 Fn,
28}
29
30#[derive(Debug)]
32pub struct ExportItem {
33 pub kind: ExportKind,
35 pub(crate) name: String,
36 pub source: String,
38}
39
40pub 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 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 let source = format!("{}\n", text.trim_start());
94 out.push(ExportItem { kind, name, source });
95 }
96}
97
98const 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
166const SYSTEM_ROOTS: [&str; 3] = ["std::", "core::", "alloc::"];
168
169const 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
176const PATH_SEP: &str = "::";
178const USE: &str = "use ";
180const GLOB: &str = "*";
182
183#[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
200fn 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
213struct Resolver<'a> {
215 uses: &'a [String],
216 local: HashSet<&'a str>,
218}
219
220impl Resolver<'_> {
221 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
238fn 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
261fn 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
280fn 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
294pub 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
323fn 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
362fn 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
371pub(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
381pub 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 Some(format!(
390 "pub {};",
391 sig.split_whitespace().collect::<Vec<_>>().join(" ")
392 ))
393}
394
395pub 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
411pub struct CrateMeta<'a> {
413 pub name: &'a str,
415 pub version: &'a str,
417 pub edition: &'a str,
419 pub authors: &'a str,
421 pub license: &'a str,
423 pub mod_id: &'a str,
425 pub yog_api: &'a str,
427}
428
429pub 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
459pub 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 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 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 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 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 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}