1use std::collections::HashSet;
5use std::fmt::Write as _;
6use std::path::{Path, PathBuf};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum ExportKind {
11 Struct,
12 Enum,
13 Fn,
14}
15
16#[derive(Debug)]
18pub struct ExportItem {
19 pub kind: ExportKind,
20 pub name: String,
21 pub source: String,
23}
24
25pub fn scan(src: &str) -> Vec<ExportItem> {
30 let Ok(file) = syn::parse_file(src) else {
31 return Vec::new();
32 };
33 let mut out = Vec::new();
34 collect(&file.items, src, &mut out);
35 out
36}
37
38fn collect(items: &[syn::Item], src: &str, out: &mut Vec<ExportItem>) {
39 use syn::spanned::Spanned;
40 for item in items {
41 let (attrs, vis, kind, name) = match item {
42 syn::Item::Struct(s) => (&s.attrs, &s.vis, ExportKind::Struct, s.ident.to_string()),
43 syn::Item::Enum(e) => (&e.attrs, &e.vis, ExportKind::Enum, e.ident.to_string()),
44 syn::Item::Fn(f) => (&f.attrs, &f.vis, ExportKind::Fn, f.sig.ident.to_string()),
45 syn::Item::Mod(m) => {
46 if let Some((_, inner)) = &m.content {
47 collect(inner, src, out);
48 }
49 continue;
50 }
51 _ => continue,
52 };
53 let is_export = |a: &syn::Attribute| a.path().segments.last().is_some_and(|s| s.ident == "yog_export");
54 if !attrs.iter().any(is_export) || !matches!(vis, syn::Visibility::Public(_)) {
55 continue;
56 }
57 let whole = item.span().byte_range();
58 let mut text = String::new();
60 let mut at = whole.start;
61 for a in attrs.iter().filter(|a| is_export(a)) {
62 let r = a.span().byte_range();
63 text.push_str(&src[at..r.start]);
64 at = r.end;
65 let rest = &src[at..whole.end];
66 let blank = rest.len() - rest.trim_start_matches([' ', '\t']).len();
67 if rest[blank..].starts_with('\n') {
68 at += blank + 1;
69 }
70 }
71 text.push_str(&src[at..whole.end]);
72 let source = format!("{}\n", text.trim_start());
74 out.push(ExportItem { kind, name, source });
75 }
76}
77
78const PRIMITIVES: &[&str] = &[
81 "bool",
82 "u8",
83 "u16",
84 "u32",
85 "u64",
86 "u128",
87 "usize",
88 "i8",
89 "i16",
90 "i32",
91 "i64",
92 "i128",
93 "isize",
94 "f32",
95 "f64",
96 "char",
97 "str",
98 "String",
99 "Vec",
100 "Option",
101 "Result",
102 "Box",
103 "Rc",
104 "Arc",
105 "Cow",
106 "PhantomData",
107 "Ordering",
108 "Self",
109 "()",
110 "HashMap",
111 "HashSet",
112 "BTreeMap",
113 "BTreeSet",
114 "LinkedList",
115 "VecDeque",
116 "BinaryHeap",
117 "Path",
118 "PathBuf",
119 "Duration",
120 "Instant",
121 "SystemTime",
122 "Error",
123 "fmt",
124 "ops",
125 "cmp",
126 "iter",
127 "slice",
128 "cell",
129 "sync",
130 "collections",
131 "convert",
132 "marker",
133 "mem",
134 "ptr",
135 "hint",
136 "borrow",
137 "hash",
138 "default",
139 "num",
140 "array",
141 "range",
142 "std::",
143 "core::",
144];
145
146const SYSTEM_ROOTS: [&str; 3] = ["std::", "core::", "alloc::"];
148
149const LOCAL_ROOTS: [&str; 3] = ["crate", "super", "self"];
151
152fn is_system(path: &str) -> bool {
153 SYSTEM_ROOTS.iter().any(|p| path.starts_with(p))
154}
155
156pub fn collect_uses(exports: &[ExportItem], sources: &[String]) -> (Vec<String>, Vec<String>) {
170 let primitives: HashSet<&str> = PRIMITIVES.iter().copied().collect();
172
173 let local_names: HashSet<String> = exports.iter().map(|e| e.name.clone()).collect();
175
176 let mut import_paths: Vec<String> = Vec::new(); let mut system_imports: HashSet<String> = HashSet::new();
180 let mut type_imports: HashSet<String> = HashSet::new();
181 for line in sources.iter().flat_map(|src| src.lines()) {
182 let t = line.trim();
183 if !t.starts_with("use ") {
184 continue;
185 }
186 let path_part = t["use ".len()..].trim_end_matches(';').trim();
187 if path_part.is_empty() {
188 continue;
189 }
190 if path_part.contains('{') && !path_part.contains('}') {
192 continue;
193 }
194 if is_system(path_part) {
196 system_imports.insert(path_part.to_string());
197 } else {
198 type_imports.insert(path_part.to_string());
199 }
200 import_paths.push(path_part.to_string());
201 }
202
203 fn resolve_name(
205 name: &str,
206 import_paths: &[String],
207 local_names: &HashSet<String>,
208 primitives: &HashSet<&str>,
209 ) -> Option<String> {
210 if primitives.contains(name) {
212 return None;
213 }
214 if is_system(name) {
215 return None;
216 }
217 if local_names.contains(name) {
219 return None;
220 }
221 let mut best: Option<(usize, String)> = None;
224 for p in import_paths {
225 let last = p.rsplit("::").next().unwrap_or(p);
226 if last == name || last == "*" {
227 let len = p.len();
228 if best.as_ref().map(|(l, _)| len < *l).unwrap_or(true) {
229 best = Some((len, p.clone()));
230 }
231 }
232 }
233 best.map(|(_, p)| p)
234 }
235
236 for item in exports {
238 let body = &item.source;
242 for m in body.match_indices("::") {
244 let start = m.0;
245 let slice = &body[start..];
247 let mut end = 0;
248 let mut depth: i32 = 0;
249 for (i, ch) in slice.char_indices() {
250 match ch {
251 '<' => depth += 1,
252 '>' => depth -= 1,
253 ' ' | ',' | ';' | '{' | '}' | '(' | ')' | ':' | '[' | ']' | '.'
254 if depth == 0 =>
255 {
256 end = i;
257 break;
258 }
259 _ => {}
260 }
261 if depth < 0 {
262 end = i;
263 break;
264 }
265 }
266 let path = slice[..end].trim().trim_end_matches("::");
267 if path.is_empty() {
268 continue;
269 }
270 let first = path
271 .trim_start_matches(':')
272 .split("::")
273 .next()
274 .unwrap_or("");
275 if is_system(path) {
276 system_imports.insert(path.to_string());
277 } else if first == "yog_api" || LOCAL_ROOTS.contains(&first) {
278 type_imports.insert(path.to_string());
279 }
280 }
281 let re_check: Vec<&str> = body
283 .split(|c: char| !c.is_alphanumeric() && c != '_')
284 .filter(|s| !s.is_empty())
285 .collect();
286 for tok in re_check {
287 if tok
288 .chars()
289 .next()
290 .map(|c| c.is_uppercase())
291 .unwrap_or(false)
292 && let Some(p) = resolve_name(tok, &import_paths, &local_names, &primitives)
293 {
294 if is_system(&p) {
295 system_imports.insert(p);
296 } else {
297 type_imports.insert(p);
298 }
299 }
300 }
301 }
302
303 fn normalize(vec: &mut Vec<String>) {
306 let mut groups: std::collections::BTreeMap<String, std::collections::BTreeSet<String>> =
307 Default::default();
308 let mut nested: Vec<String> = Vec::new();
309 for p in vec.drain(..) {
310 let p = p.trim().to_string();
311 if let Some((prefix, rest)) = p.split_once("::{") {
312 let inside = rest.strip_suffix('}').unwrap_or(rest);
313 if inside.contains('{') {
314 nested.push(p);
315 continue;
316 }
317 let set = groups.entry(prefix.to_string()).or_default();
318 set.extend(
319 inside
320 .split(',')
321 .map(|s| s.trim())
322 .filter(|s| !s.is_empty())
323 .map(str::to_string),
324 );
325 } else if let Some((prefix, item)) = p.rsplit_once("::") {
326 groups
327 .entry(prefix.to_string())
328 .or_default()
329 .insert(item.to_string());
330 } else {
331 nested.push(p);
332 }
333 }
334 let mut keep: Vec<String> = groups
335 .into_iter()
336 .map(|(prefix, items)| {
337 if items.len() == 1 {
338 format!("{prefix}::{}", items.into_iter().next().unwrap())
339 } else {
340 let items: Vec<String> = items.into_iter().collect();
341 format!("{prefix}::{{{}}}", items.join(", "))
342 }
343 })
344 .collect();
345 nested.sort();
346 nested.dedup();
347 keep.extend(nested);
348 keep.sort();
349 *vec = keep;
350 }
351
352 let mut system_vec: Vec<String> = system_imports.into_iter().collect();
353 let mut type_vec: Vec<String> = type_imports.into_iter().collect();
354 normalize(&mut system_vec);
355 normalize(&mut type_vec);
356
357 (system_vec, type_vec)
358}
359
360pub fn strip_derive_attrs(source: &str) -> String {
363 source
364 .lines()
365 .filter(|line| !line.trim().starts_with("#[derive("))
366 .collect::<Vec<_>>()
367 .join("\n")
368}
369
370fn fn_signature(source: &str) -> (Vec<(String, String)>, String) {
393 use syn::spanned::Spanned;
394 let Ok(f) = syn::parse_str::<syn::ItemFn>(source) else {
395 return (Vec::new(), "()".into());
396 };
397 let text = |s: proc_macro2::Span| source[s.byte_range()].to_string();
398 let params = f
399 .sig
400 .inputs
401 .iter()
402 .filter_map(|a| match a {
403 syn::FnArg::Typed(t) => Some((text(t.pat.span()), text(t.ty.span()))),
404 syn::FnArg::Receiver(_) => None,
405 })
406 .collect();
407 let output = match &f.sig.output {
408 syn::ReturnType::Default => "()".into(),
409 syn::ReturnType::Type(_, ty) => text(ty.span()),
410 };
411 (params, output)
412}
413
414pub fn generate_fn_wrapper(mod_id: &str, name: &str, source: &str) -> String {
415 let (mut typed_params, output_type) = fn_signature(source);
416
417 if typed_params
422 .first()
423 .map(|(_, ty)| ty.replace(' ', "").ends_with("Registry"))
424 .unwrap_or(false)
425 {
426 typed_params.remove(0);
427 }
428
429 let arg_names: Vec<String> = typed_params
432 .iter()
433 .enumerate()
434 .map(|(i, (pat, _))| {
435 let name = pat.trim().trim_start_matches("mut ").trim();
436 let plain = !name.is_empty()
437 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
438 && !name.starts_with(|c: char| c.is_ascii_digit());
439 if plain { name.to_string() } else { format!("__yog_arg{i}") }
440 })
441 .collect();
442 let data_types: Vec<&str> = typed_params.iter().map(|(_, ty)| ty.as_str()).collect();
443
444 let params_sig = arg_names
445 .iter()
446 .zip(data_types.iter())
447 .map(|(n, t)| format!("{n}: {t}"))
448 .collect::<Vec<_>>()
449 .join(", ");
450
451 let tuple_expr = match arg_names.len() {
452 0 => String::new(),
453 1 => arg_names[0].clone(),
454 _ => format!("({})", arg_names.join(", ")),
455 };
456
457 let slot_name = format!("__YOG_SLOT_{}", name.to_uppercase());
460 let bind_name = format!("__yog_bind_{}", name);
461
462 let output_type_fmt = output_type.replace('{', "{{").replace('}', "}}");
465 let params_sig_fmt = params_sig.replace('{', "{{").replace('}', "}}");
466 let slot_name_fmt = slot_name.replace('{', "{{").replace('}', "}}");
467 let bind_name_fmt = bind_name.replace('{', "{{").replace('}', "}}");
468
469 let wrapper_ty = "::yog_api::YogExportFn".to_string();
471
472 let serialize_block = if tuple_expr.is_empty() {
473 "let input_bytes: Vec<u8> = Vec::new();".to_string()
474 } else {
475 format!(
476 "let aligned = ::yog_api::rkyv::to_bytes::<::yog_api::rkyv::rancor::Error>(\
477 &{tuple_expr}\
478 ).unwrap_or_default();\n \
479 let input_bytes: Vec<u8> = aligned.to_vec();"
480 )
481 };
482
483 format!(
484 r#"#[allow(non_snake_case)]
485pub fn {name}({params_sig_fmt}) -> {output_type_fmt} {{
486 {serialize_block}
487 let f = {slot_name_fmt}.get()
488 .expect(concat!("yog: export '", "{name}", "' not bound"));
489 let output = ::yog_api::__interop_call(*f, &input_bytes, "{name}");
490 ::yog_api::rkyv::from_bytes::<{output_type_fmt}, ::yog_api::rkyv::rancor::Error>(&output)
491 .expect(concat!("yog: the result of export '", "{name}", "' didn't decode"))
492}}
493
494static {slot_name_fmt}: ::std::sync::OnceLock<{wrapper_ty}> = ::std::sync::OnceLock::new();
495
496#[doc(hidden)]
497#[unsafe(no_mangle)]
498pub unsafe extern "C" fn {bind_name_fmt}(ptr: *const ::std::os::raw::c_void) {{
499 let f: {wrapper_ty} = unsafe {{ ::std::mem::transmute(ptr) }};
500 {slot_name_fmt}.set(f).ok();
501}}
502
503// Queues the binding when the consuming mod's library loads; `export_mod!`
504// binds it once `{mod_id}` has exported `{name}`.
505#[::yog_api::ctor::ctor(unsafe, crate_path = ::yog_api::ctor)]
506#[doc(hidden)]
507fn __yog_import_ctor_{name}() {{
508 ::yog_api::__yog_import_registry().lock().unwrap().push(::yog_api::YogImportEntry {{
509 mod_id: "{mod_id}",
510 symbol: "{name}",
511 bind_fn: {bind_name_fmt} as *const () as usize,
512 }});
513}}
514"#
515 )
516}
517
518pub fn collect_rs_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
521 let mut out = Vec::new();
522 for entry in std::fs::read_dir(dir).map_err(|e| e.to_string())? {
523 let entry = entry.map_err(|e| e.to_string())?;
524 let path = entry.path();
525 if path.is_dir() {
526 out.extend(collect_rs_files(&path)?);
527 } else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
528 out.push(path);
529 }
530 }
531 Ok(out)
532}
533
534pub struct CrateMeta<'a> {
536 pub name: &'a str,
537 pub version: &'a str,
538 pub edition: &'a str,
539 pub authors: &'a str,
541 pub license: &'a str,
542 pub mod_id: &'a str,
543 pub yog_api: &'a str,
544 pub rkyv: &'a str,
545}
546
547pub fn cargo_toml(m: &CrateMeta) -> String {
549 let maybe_authors = if m.authors.is_empty() {
550 String::new()
551 } else {
552 format!("authors = [\"{}\"]\n", m.authors.replace(", ", "\", \""))
553 };
554 format!(
555 r#"[package]
556name = "{name}"
557version = "{version}"
558edition = "{edition}"
559{maybe_authors}license = "{license}"
560description = "Exports from the {mod_id} Yog mod — generated by `yog publish exports`."
561
562[dependencies]
563yog-api = "{yog_api}"
564rkyv = "{rkyv}"
565
566[lib]
567crate-type = ["cdylib", "lib"]
568"#,
569 name = m.name,
570 version = m.version,
571 edition = m.edition,
572 license = m.license,
573 mod_id = m.mod_id,
574 yog_api = m.yog_api,
575 rkyv = m.rkyv,
576 )
577}
578
579pub fn lib_rs(
583 mod_id: &str,
584 version: &str,
585 exports: &[ExportItem],
586 system_uses: &[String],
587 type_uses: &[String],
588) -> String {
589 let mut lib_rs = format!(
590 "//! Auto-generated exports from `{mod_id}` v{version}.\n\
591 //! DO NOT EDIT — generated by `yog publish exports`.\n\n\
592 #![allow(unused_imports)]\n\n"
593 );
594
595 if !system_uses.is_empty() {
597 lib_rs.push_str("// System dependencies\n");
598 for u in system_uses {
599 let _ = writeln!(lib_rs, "use {u};");
600 }
601 lib_rs.push('\n');
602 }
603
604 let exported_names: HashSet<&str> = exports.iter().map(|e| e.name.as_str()).collect();
607 if !type_uses.is_empty() {
608 lib_rs.push_str("// Type dependencies\n");
609 for u in type_uses {
610 let first_part = u.split("::").next().unwrap_or("");
614 if LOCAL_ROOTS.contains(&first_part) {
615 continue;
616 }
617 let last_part = u.rsplit("::").next().unwrap_or(u);
618 if exported_names.contains(last_part) {
619 continue;
620 }
621 let _ = writeln!(lib_rs, "use {u};");
622 }
623 lib_rs.push('\n');
624 }
625
626 for item in exports {
627 match item.kind {
628 ExportKind::Struct | ExportKind::Enum => {
629 let _ = writeln!(
631 lib_rs,
632 "#[derive(::yog_api::rkyv::Archive, ::yog_api::rkyv::Serialize, ::yog_api::rkyv::Deserialize, Debug, Clone, PartialEq)]\n{}",
633 strip_derive_attrs(&item.source)
634 );
635 }
636 ExportKind::Fn => lib_rs.push_str(&generate_fn_wrapper(mod_id, &item.name, &item.source)),
637 }
638 }
639 lib_rs
641}
642
643#[cfg(test)]
644mod tests {
645 use super::*;
646
647 const SRC: &str = r#"
648use yog_api::prelude::*;
649
650#[yog_export]
651pub struct Pipe { pub id: u32 }
652
653#[yog_export]
654pub enum Kind { Item, Fluid }
655
656/// Not exported.
657pub fn helper() {}
658
659#[yog_export]
660pub fn send(
661 registry: &mut Registry,
662 pipe: Pipe,
663 amount: u32,
664) -> Option<String> {
665 None
666}
667
668#[yog_export]
669pub fn count() -> u64 { 0 }
670"#;
671
672 #[test]
673 fn scans_every_exported_item_and_nothing_else() {
674 let items = scan(SRC);
675 let names: Vec<(&str, ExportKind)> = items.iter().map(|i| (i.name.as_str(), i.kind)).collect();
676 assert_eq!(names, [("Pipe", ExportKind::Struct), ("Kind", ExportKind::Enum), ("send", ExportKind::Fn), ("count", ExportKind::Fn)]);
677 assert!(!items.iter().any(|i| i.source.contains("#[yog_export]")), "the attribute is stripped");
678 assert!(items[2].source.contains("amount: u32"), "a wrapped signature is whole");
679 }
680
681 #[test]
682 fn wrappers_call_through_one_helper_and_drop_the_registry() {
683 let items = scan(SRC);
684 let w = generate_fn_wrapper("yog-pipes", "send", &items[2].source);
685 assert!(w.contains("pub fn send(pipe: Pipe, amount: u32) -> Option<String>"), "callers see the export's names: {w}");
686 assert!(w.contains("::yog_api::__interop_call("), "{w}");
687 assert!(w.contains("static __YOG_SLOT_SEND"), "{w}");
688 let none = generate_fn_wrapper("yog-pipes", "count", &items[3].source);
689 assert!(none.contains("pub fn count() -> u64"), "{none}");
690 }
691
692 #[test]
693 fn a_doc_comment_with_parentheses_keeps_the_parameters() {
694 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";
695 let item = &scan(src)[0];
696 assert!(item.source.starts_with("/// Drives"), "{}", item.source);
697 let w = generate_fn_wrapper("yog-pipes", "emit", &item.source);
698 assert!(w.contains("pub fn emit(capability: String, pos: (i32, i32), levels: HashMap<String, u8>) -> Result<(), String>"), "{w}");
699 assert!(w.contains("&(capability, pos, levels)"), "{w}");
700 }
701
702 #[test]
703 fn braces_in_strings_and_comments_dont_end_an_item() {
704 let src = r#"
705mod inner {
706 /// Opens `{` — and a doc comment can say so.
707 #[yog_export]
708 pub fn open(s: String) -> String {
709 // } not the end
710 format!("{{{s}")
711 }
712}
713
714#[yog_export]
715pub struct After { pub x: u8 }
716"#;
717 let items = scan(src);
718 let names: Vec<&str> = items.iter().map(|i| i.name.as_str()).collect();
719 assert_eq!(names, ["open", "After"]);
720 assert!(items[0].source.starts_with("/// Opens"), "{}", items[0].source);
721 assert!(items[0].source.trim_end().ends_with('}') && items[0].source.contains("format!(\"{{{s}\")"), "{}", items[0].source);
722 assert!(!items[0].source.contains("yog_export"));
723 }
724}