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
16impl ExportKind {
17 fn parse(first: &str) -> Option<(ExportKind, &str)> {
19 const KINDS: [(&str, ExportKind, &[char]); 3] = [
20 (
21 "pub struct ",
22 ExportKind::Struct,
23 &['{', '(', ' ', '<', ';', '\t'],
24 ),
25 ("pub enum ", ExportKind::Enum, &['{', ' ', '<', ';', '\t']),
26 ("pub fn ", ExportKind::Fn, &['(']),
27 ];
28 let (kind, rest, ends) = KINDS
29 .iter()
30 .find_map(|&(prefix, kind, ends)| Some((kind, first.strip_prefix(prefix)?, ends)))?;
31 Some((kind, rest.split(ends).next().unwrap_or("").trim()))
32 }
33}
34
35#[derive(Debug)]
37pub struct ExportItem {
38 pub kind: ExportKind,
39 pub name: String,
40 pub source: String,
42}
43
44pub fn scan(src: &str) -> Vec<ExportItem> {
46 let mut exports = Vec::new();
47 let lines: Vec<&str> = src.lines().collect();
48 let mut i = 0;
49 while i < lines.len() {
50 let trimmed = lines[i].trim();
51 if trimmed == "#[yog_export]" || trimmed.starts_with("#[yog_export(") {
52 i += 1;
53 while i < lines.len() && lines[i].trim() == "#[yog_export]" {
55 i += 1;
56 }
57 if i >= lines.len() {
59 break;
60 }
61 let Some((kind, name)) = ExportKind::parse(lines[i].trim()) else {
62 i += 1;
63 continue;
64 };
65 if name.is_empty() {
66 i += 1;
67 continue;
68 }
69 let name = name.to_string();
70
71 let mut depth: i32 = 0;
73 let mut found_open = false;
74 let mut item_lines: Vec<&str> = Vec::new();
75 while i < lines.len() {
76 let line = lines[i];
77 item_lines.push(line);
78 for ch in line.chars() {
79 if ch == '{' {
80 depth += 1;
81 found_open = true;
82 } else if ch == '}' {
83 depth -= 1;
84 }
85 }
86 if found_open && depth <= 0 {
87 item_lines.push("");
88 i += 1;
89 break;
90 }
91 if !found_open && line.trim().ends_with(';') {
93 i += 1;
94 break;
95 }
96 i += 1;
97 }
98 exports.push(ExportItem {
99 kind,
100 name,
101 source: item_lines.join("\n"),
102 });
103 }
104 i += 1;
105 }
106 exports
107}
108
109const PRIMITIVES: &[&str] = &[
112 "bool",
113 "u8",
114 "u16",
115 "u32",
116 "u64",
117 "u128",
118 "usize",
119 "i8",
120 "i16",
121 "i32",
122 "i64",
123 "i128",
124 "isize",
125 "f32",
126 "f64",
127 "char",
128 "str",
129 "String",
130 "Vec",
131 "Option",
132 "Result",
133 "Box",
134 "Rc",
135 "Arc",
136 "Cow",
137 "PhantomData",
138 "Ordering",
139 "Self",
140 "()",
141 "HashMap",
142 "HashSet",
143 "BTreeMap",
144 "BTreeSet",
145 "LinkedList",
146 "VecDeque",
147 "BinaryHeap",
148 "Path",
149 "PathBuf",
150 "Duration",
151 "Instant",
152 "SystemTime",
153 "Error",
154 "fmt",
155 "ops",
156 "cmp",
157 "iter",
158 "slice",
159 "cell",
160 "sync",
161 "collections",
162 "convert",
163 "marker",
164 "mem",
165 "ptr",
166 "hint",
167 "borrow",
168 "hash",
169 "default",
170 "num",
171 "array",
172 "range",
173 "std::",
174 "core::",
175];
176
177const SYSTEM_ROOTS: [&str; 3] = ["std::", "core::", "alloc::"];
179
180const LOCAL_ROOTS: [&str; 3] = ["crate", "super", "self"];
182
183fn is_system(path: &str) -> bool {
184 SYSTEM_ROOTS.iter().any(|p| path.starts_with(p))
185}
186
187pub fn collect_uses(exports: &[ExportItem], sources: &[String]) -> (Vec<String>, Vec<String>) {
201 let primitives: HashSet<&str> = PRIMITIVES.iter().copied().collect();
203
204 let local_names: HashSet<String> = exports.iter().map(|e| e.name.clone()).collect();
206
207 let mut import_paths: Vec<String> = Vec::new(); let mut system_imports: HashSet<String> = HashSet::new();
211 let mut type_imports: HashSet<String> = HashSet::new();
212 for line in sources.iter().flat_map(|src| src.lines()) {
213 let t = line.trim();
214 if !t.starts_with("use ") {
215 continue;
216 }
217 let path_part = t["use ".len()..].trim_end_matches(';').trim();
218 if path_part.is_empty() {
219 continue;
220 }
221 if path_part.contains('{') && !path_part.contains('}') {
223 continue;
224 }
225 if is_system(path_part) {
227 system_imports.insert(path_part.to_string());
228 } else {
229 type_imports.insert(path_part.to_string());
230 }
231 import_paths.push(path_part.to_string());
232 }
233
234 fn resolve_name(
236 name: &str,
237 import_paths: &[String],
238 local_names: &HashSet<String>,
239 primitives: &HashSet<&str>,
240 ) -> Option<String> {
241 if primitives.contains(name) {
243 return None;
244 }
245 if is_system(name) {
246 return None;
247 }
248 if local_names.contains(name) {
250 return None;
251 }
252 let mut best: Option<(usize, String)> = None;
255 for p in import_paths {
256 let last = p.rsplit("::").next().unwrap_or(p);
257 if last == name || last == "*" {
258 let len = p.len();
259 if best.as_ref().map(|(l, _)| len < *l).unwrap_or(true) {
260 best = Some((len, p.clone()));
261 }
262 }
263 }
264 best.map(|(_, p)| p)
265 }
266
267 for item in exports {
269 let body = &item.source;
273 for m in body.match_indices("::") {
275 let start = m.0;
276 let slice = &body[start..];
278 let mut end = 0;
279 let mut depth: i32 = 0;
280 for (i, ch) in slice.char_indices() {
281 match ch {
282 '<' => depth += 1,
283 '>' => depth -= 1,
284 ' ' | ',' | ';' | '{' | '}' | '(' | ')' | ':' | '[' | ']' | '.'
285 if depth == 0 =>
286 {
287 end = i;
288 break;
289 }
290 _ => {}
291 }
292 if depth < 0 {
293 end = i;
294 break;
295 }
296 }
297 let path = slice[..end].trim().trim_end_matches("::");
298 if path.is_empty() {
299 continue;
300 }
301 let first = path
302 .trim_start_matches(':')
303 .split("::")
304 .next()
305 .unwrap_or("");
306 if is_system(path) {
307 system_imports.insert(path.to_string());
308 } else if first == "yog_api" || LOCAL_ROOTS.contains(&first) {
309 type_imports.insert(path.to_string());
310 }
311 }
312 let re_check: Vec<&str> = body
314 .split(|c: char| !c.is_alphanumeric() && c != '_')
315 .filter(|s| !s.is_empty())
316 .collect();
317 for tok in re_check {
318 if tok
319 .chars()
320 .next()
321 .map(|c| c.is_uppercase())
322 .unwrap_or(false)
323 && let Some(p) = resolve_name(tok, &import_paths, &local_names, &primitives)
324 {
325 if is_system(&p) {
326 system_imports.insert(p);
327 } else {
328 type_imports.insert(p);
329 }
330 }
331 }
332 }
333
334 fn normalize(vec: &mut Vec<String>) {
337 let mut groups: std::collections::BTreeMap<String, std::collections::BTreeSet<String>> =
338 Default::default();
339 let mut nested: Vec<String> = Vec::new();
340 for p in vec.drain(..) {
341 let p = p.trim().to_string();
342 if let Some((prefix, rest)) = p.split_once("::{") {
343 let inside = rest.strip_suffix('}').unwrap_or(rest);
344 if inside.contains('{') {
345 nested.push(p);
346 continue;
347 }
348 let set = groups.entry(prefix.to_string()).or_default();
349 set.extend(
350 inside
351 .split(',')
352 .map(|s| s.trim())
353 .filter(|s| !s.is_empty())
354 .map(str::to_string),
355 );
356 } else if let Some((prefix, item)) = p.rsplit_once("::") {
357 groups
358 .entry(prefix.to_string())
359 .or_default()
360 .insert(item.to_string());
361 } else {
362 nested.push(p);
363 }
364 }
365 let mut keep: Vec<String> = groups
366 .into_iter()
367 .map(|(prefix, items)| {
368 if items.len() == 1 {
369 format!("{prefix}::{}", items.into_iter().next().unwrap())
370 } else {
371 let items: Vec<String> = items.into_iter().collect();
372 format!("{prefix}::{{{}}}", items.join(", "))
373 }
374 })
375 .collect();
376 nested.sort();
377 nested.dedup();
378 keep.extend(nested);
379 keep.sort();
380 *vec = keep;
381 }
382
383 let mut system_vec: Vec<String> = system_imports.into_iter().collect();
384 let mut type_vec: Vec<String> = type_imports.into_iter().collect();
385 normalize(&mut system_vec);
386 normalize(&mut type_vec);
387
388 (system_vec, type_vec)
389}
390
391pub fn strip_derive_attrs(source: &str) -> String {
394 source
395 .lines()
396 .filter(|line| !line.trim().starts_with("#[derive("))
397 .collect::<Vec<_>>()
398 .join("\n")
399}
400
401pub fn generate_fn_wrapper(mod_id: &str, name: &str, source: &str) -> String {
421 let sig_line = source.lines().map(str::trim).collect::<Vec<_>>().join(" ");
426 let sig_line = sig_line.as_str();
427
428 let paren_start = sig_line.find('(');
431 let paren_end = paren_start.and_then(|ps| {
432 let after = &sig_line[ps..];
433 let mut depth: i32 = 0;
434 for (i, ch) in after.char_indices() {
435 match ch {
436 '(' => depth += 1,
437 ')' => {
438 depth -= 1;
439 if depth == 0 {
440 return Some(ps + i);
441 }
442 }
443 _ => {}
444 }
445 }
446 None
447 });
448
449 fn split_params(params: &str) -> Vec<&str> {
452 let mut result = Vec::new();
453 let mut depth: i32 = 0;
454 let mut start = 0;
455 for (i, ch) in params.char_indices() {
456 match ch {
457 '<' | '(' | '[' => depth += 1,
458 '>' | ')' | ']' => depth -= 1,
459 ',' if depth == 0 => {
460 result.push(params[start..i].trim());
461 start = i + 1;
462 }
463 _ => {}
464 }
465 }
466 let last = params[start..].trim();
467 if !last.is_empty() {
468 result.push(last);
469 }
470 result
471 }
472
473 let mut typed_params: Vec<(String, String)> =
475 if let (Some(ps), Some(pe)) = (paren_start, paren_end) {
476 let params = sig_line[ps + 1..pe].trim();
477 split_params(params)
478 .into_iter()
479 .filter_map(|p| {
480 let parts: Vec<&str> = p.splitn(2, ':').collect();
481 if parts.len() == 2 {
482 Some((parts[0].trim().to_string(), parts[1].trim().to_string()))
483 } else {
484 None
485 }
486 })
487 .collect()
488 } else {
489 Vec::new()
490 };
491
492 if typed_params
497 .first()
498 .map(|(_, ty)| ty.replace(' ', "").ends_with("Registry"))
499 .unwrap_or(false)
500 {
501 typed_params.remove(0);
502 }
503
504 let arg_names: Vec<String> = typed_params
507 .iter()
508 .enumerate()
509 .map(|(i, (pat, _))| {
510 let name = pat.trim().trim_start_matches("mut ").trim();
511 let plain = !name.is_empty()
512 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
513 && !name.starts_with(|c: char| c.is_ascii_digit());
514 if plain { name.to_string() } else { format!("__yog_arg{i}") }
515 })
516 .collect();
517 let data_types: Vec<&str> = typed_params.iter().map(|(_, ty)| ty.as_str()).collect();
518
519 let params_sig = arg_names
520 .iter()
521 .zip(data_types.iter())
522 .map(|(n, t)| format!("{n}: {t}"))
523 .collect::<Vec<_>>()
524 .join(", ");
525
526 let tuple_expr = match arg_names.len() {
527 0 => String::new(),
528 1 => arg_names[0].clone(),
529 _ => format!("({})", arg_names.join(", ")),
530 };
531
532 let output_type = match paren_end {
535 Some(pe) => {
536 let arrow_rel = if pe < sig_line.len() {
537 sig_line[pe..].find("->")
538 } else {
539 None
540 };
541 match arrow_rel {
542 Some(rel) => {
543 let arrow = pe + rel;
544 let rest: &str = sig_line[arrow + 2..].trim();
545 let mut depth: i32 = 0;
547 let mut end = 0;
548 for (i, ch) in rest.char_indices() {
549 match ch {
550 '<' => depth += 1,
551 '>' => depth -= 1,
552 '{' | ';' => break,
553 _ => {}
554 }
555 if depth < 0 {
556 break;
557 }
558 if ch == '=' {
559 break;
560 }
561 end = i + ch.len_utf8();
562 }
563 rest[..end].trim().to_string()
564 }
565 None => "()".to_string(),
566 }
567 }
568 None => "()".to_string(),
569 };
570
571 let slot_name = format!("__YOG_SLOT_{}", name.to_uppercase());
574 let bind_name = format!("__yog_bind_{}", name);
575
576 let output_type_fmt = output_type.replace('{', "{{").replace('}', "}}");
579 let params_sig_fmt = params_sig.replace('{', "{{").replace('}', "}}");
580 let slot_name_fmt = slot_name.replace('{', "{{").replace('}', "}}");
581 let bind_name_fmt = bind_name.replace('{', "{{").replace('}', "}}");
582
583 let wrapper_ty = "::yog_api::YogExportFn".to_string();
585
586 let serialize_block = if tuple_expr.is_empty() {
587 "let input_bytes: Vec<u8> = Vec::new();".to_string()
588 } else {
589 format!(
590 "let aligned = ::yog_api::rkyv::to_bytes::<::yog_api::rkyv::rancor::Error>(\
591 &{tuple_expr}\
592 ).unwrap_or_default();\n \
593 let input_bytes: Vec<u8> = aligned.to_vec();"
594 )
595 };
596
597 format!(
598 r#"#[allow(non_snake_case)]
599pub fn {name}({params_sig_fmt}) -> {output_type_fmt} {{
600 {serialize_block}
601 let f = {slot_name_fmt}.get()
602 .expect(concat!("yog: export '", "{name}", "' not bound"));
603 let output = ::yog_api::__interop_call(*f, &input_bytes, "{name}");
604 ::yog_api::rkyv::from_bytes::<{output_type_fmt}, ::yog_api::rkyv::rancor::Error>(&output)
605 .expect(concat!("yog: the result of export '", "{name}", "' didn't decode"))
606}}
607
608static {slot_name_fmt}: ::std::sync::OnceLock<{wrapper_ty}> = ::std::sync::OnceLock::new();
609
610#[doc(hidden)]
611#[unsafe(no_mangle)]
612pub unsafe extern "C" fn {bind_name_fmt}(ptr: *const ::std::os::raw::c_void) {{
613 let f: {wrapper_ty} = unsafe {{ ::std::mem::transmute(ptr) }};
614 {slot_name_fmt}.set(f).ok();
615}}
616
617// Queues the binding when the consuming mod's library loads; `export_mod!`
618// binds it once `{mod_id}` has exported `{name}`.
619#[::yog_api::ctor::ctor(unsafe, crate_path = ::yog_api::ctor)]
620#[doc(hidden)]
621fn __yog_import_ctor_{name}() {{
622 ::yog_api::__yog_import_registry().lock().unwrap().push(::yog_api::YogImportEntry {{
623 mod_id: "{mod_id}",
624 symbol: "{name}",
625 bind_fn: {bind_name_fmt} as *const () as usize,
626 }});
627}}
628"#
629 )
630}
631
632pub fn collect_rs_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
635 let mut out = Vec::new();
636 for entry in std::fs::read_dir(dir).map_err(|e| e.to_string())? {
637 let entry = entry.map_err(|e| e.to_string())?;
638 let path = entry.path();
639 if path.is_dir() {
640 out.extend(collect_rs_files(&path)?);
641 } else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
642 out.push(path);
643 }
644 }
645 Ok(out)
646}
647
648pub struct CrateMeta<'a> {
650 pub name: &'a str,
651 pub version: &'a str,
652 pub edition: &'a str,
653 pub authors: &'a str,
655 pub license: &'a str,
656 pub mod_id: &'a str,
657 pub yog_api: &'a str,
658 pub rkyv: &'a str,
659}
660
661pub fn cargo_toml(m: &CrateMeta) -> String {
663 let maybe_authors = if m.authors.is_empty() {
664 String::new()
665 } else {
666 format!("authors = [\"{}\"]\n", m.authors.replace(", ", "\", \""))
667 };
668 format!(
669 r#"[package]
670name = "{name}"
671version = "{version}"
672edition = "{edition}"
673{maybe_authors}license = "{license}"
674description = "Exports from the {mod_id} Yog mod — generated by `yog publish exports`."
675
676[dependencies]
677yog-api = "{yog_api}"
678rkyv = "{rkyv}"
679
680[lib]
681crate-type = ["cdylib", "lib"]
682"#,
683 name = m.name,
684 version = m.version,
685 edition = m.edition,
686 license = m.license,
687 mod_id = m.mod_id,
688 yog_api = m.yog_api,
689 rkyv = m.rkyv,
690 )
691}
692
693pub fn lib_rs(
697 mod_id: &str,
698 version: &str,
699 exports: &[ExportItem],
700 system_uses: &[String],
701 type_uses: &[String],
702) -> String {
703 let mut lib_rs = format!(
704 "//! Auto-generated exports from `{mod_id}` v{version}.\n\
705 //! DO NOT EDIT — generated by `yog publish exports`.\n\n\
706 #![allow(unused_imports)]\n\n"
707 );
708
709 if !system_uses.is_empty() {
711 lib_rs.push_str("// System dependencies\n");
712 for u in system_uses {
713 let _ = writeln!(lib_rs, "use {u};");
714 }
715 lib_rs.push('\n');
716 }
717
718 let exported_names: HashSet<&str> = exports.iter().map(|e| e.name.as_str()).collect();
721 if !type_uses.is_empty() {
722 lib_rs.push_str("// Type dependencies\n");
723 for u in type_uses {
724 let first_part = u.split("::").next().unwrap_or("");
728 if LOCAL_ROOTS.contains(&first_part) {
729 continue;
730 }
731 let last_part = u.rsplit("::").next().unwrap_or(u);
732 if exported_names.contains(last_part) {
733 continue;
734 }
735 let _ = writeln!(lib_rs, "use {u};");
736 }
737 lib_rs.push('\n');
738 }
739
740 for item in exports {
741 match item.kind {
742 ExportKind::Struct | ExportKind::Enum => {
743 let _ = writeln!(
745 lib_rs,
746 "#[derive(::yog_api::rkyv::Archive, ::yog_api::rkyv::Serialize, ::yog_api::rkyv::Deserialize, Debug, Clone, PartialEq)]\n{}",
747 strip_derive_attrs(&item.source)
748 );
749 }
750 ExportKind::Fn => lib_rs.push_str(&generate_fn_wrapper(mod_id, &item.name, &item.source)),
751 }
752 }
753 lib_rs
755}
756
757#[cfg(test)]
758mod tests {
759 use super::*;
760
761 const SRC: &str = r#"
762use yog_api::prelude::*;
763
764#[yog_export]
765pub struct Pipe { pub id: u32 }
766
767#[yog_export]
768pub enum Kind { Item, Fluid }
769
770/// Not exported.
771pub fn helper() {}
772
773#[yog_export]
774pub fn send(
775 registry: &mut Registry,
776 pipe: Pipe,
777 amount: u32,
778) -> Option<String> {
779 None
780}
781
782#[yog_export]
783pub fn count() -> u64 { 0 }
784"#;
785
786 #[test]
787 fn scans_every_exported_item_and_nothing_else() {
788 let items = scan(SRC);
789 let names: Vec<(&str, ExportKind)> = items.iter().map(|i| (i.name.as_str(), i.kind)).collect();
790 assert_eq!(names, [("Pipe", ExportKind::Struct), ("Kind", ExportKind::Enum), ("send", ExportKind::Fn), ("count", ExportKind::Fn)]);
791 assert!(!items.iter().any(|i| i.source.contains("#[yog_export]")), "the attribute is stripped");
792 assert!(items[2].source.contains("amount: u32"), "a wrapped signature is whole");
793 }
794
795 #[test]
796 fn wrappers_call_through_one_helper_and_drop_the_registry() {
797 let items = scan(SRC);
798 let w = generate_fn_wrapper("yog-pipes", "send", &items[2].source);
799 assert!(w.contains("pub fn send(pipe: Pipe, amount: u32) -> Option<String>"), "callers see the export's names: {w}");
800 assert!(w.contains("::yog_api::__interop_call("), "{w}");
801 assert!(w.contains("static __YOG_SLOT_SEND"), "{w}");
802 let none = generate_fn_wrapper("yog-pipes", "count", &items[3].source);
803 assert!(none.contains("pub fn count() -> u64"), "{none}");
804 }
805}