1use std::collections::{BTreeMap, BTreeSet, HashSet};
5use std::fmt::Write as _;
6use std::path::{Path, PathBuf};
7
8pub fn crate_name(mod_id: &str) -> String {
11 format!("{}{EXPORTS_SUFFIX}", yog_ids::rust_name(mod_id))
12}
13
14const EXPORTS_SUFFIX: &str = "_exports";
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum ExportKind {
20 Struct,
22 Enum,
24 Fn,
26}
27
28#[derive(Debug)]
30pub struct ExportItem {
31 pub kind: ExportKind,
33 pub(crate) name: String,
34 pub source: String,
36}
37
38pub 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 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 let source = format!("{}\n", text.trim_start());
92 out.push(ExportItem { kind, name, source });
93 }
94}
95
96const 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
164const SYSTEM_ROOTS: [&str; 3] = ["std::", "core::", "alloc::"];
166
167const 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
174const PATH_SEP: &str = "::";
176const USE: &str = "use ";
178const GLOB: &str = "*";
180
181#[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
198fn 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
211struct Resolver<'a> {
213 uses: &'a [String],
214 local: HashSet<&'a str>,
216}
217
218impl Resolver<'_> {
219 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
236fn 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
259fn 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
278fn 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
292pub 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
321fn 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
360fn 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
369pub(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
379pub 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 Some(format!(
388 "pub {};",
389 sig.split_whitespace().collect::<Vec<_>>().join(" ")
390 ))
391}
392
393pub 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
409pub struct CrateMeta<'a> {
411 pub name: &'a str,
413 pub version: &'a str,
415 pub edition: &'a str,
417 pub authors: &'a str,
419 pub license: &'a str,
421 pub mod_id: &'a str,
423 pub yog_api: &'a str,
425}
426
427pub 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
457pub 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 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 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 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 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 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}