use std::collections::HashSet;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExportKind {
Struct,
Enum,
Fn,
}
#[derive(Debug)]
pub struct ExportItem {
pub kind: ExportKind,
pub name: String,
pub source: String,
}
pub fn scan(src: &str) -> Vec<ExportItem> {
let Ok(file) = syn::parse_file(src) else {
return Vec::new();
};
let mut out = Vec::new();
collect(&file.items, src, &mut out);
out
}
fn collect(items: &[syn::Item], src: &str, out: &mut Vec<ExportItem>) {
use syn::spanned::Spanned;
for item in items {
let (attrs, vis, kind, name) = match item {
syn::Item::Struct(s) => (&s.attrs, &s.vis, ExportKind::Struct, s.ident.to_string()),
syn::Item::Enum(e) => (&e.attrs, &e.vis, ExportKind::Enum, e.ident.to_string()),
syn::Item::Fn(f) => (&f.attrs, &f.vis, ExportKind::Fn, f.sig.ident.to_string()),
syn::Item::Mod(m) => {
if let Some((_, inner)) = &m.content {
collect(inner, src, out);
}
continue;
}
_ => continue,
};
let is_export = |a: &syn::Attribute| a.path().segments.last().is_some_and(|s| s.ident == "yog_export");
if !attrs.iter().any(is_export) || !matches!(vis, syn::Visibility::Public(_)) {
continue;
}
let whole = item.span().byte_range();
let mut text = String::new();
let mut at = whole.start;
for a in attrs.iter().filter(|a| is_export(a)) {
let r = a.span().byte_range();
text.push_str(&src[at..r.start]);
at = r.end;
let rest = &src[at..whole.end];
let blank = rest.len() - rest.trim_start_matches([' ', '\t']).len();
if rest[blank..].starts_with('\n') {
at += blank + 1;
}
}
text.push_str(&src[at..whole.end]);
let source = format!("{}\n", text.trim_start());
out.push(ExportItem { kind, name, source });
}
}
const PRIMITIVES: &[&str] = &[
"bool",
"u8",
"u16",
"u32",
"u64",
"u128",
"usize",
"i8",
"i16",
"i32",
"i64",
"i128",
"isize",
"f32",
"f64",
"char",
"str",
"String",
"Vec",
"Option",
"Result",
"Box",
"Rc",
"Arc",
"Cow",
"PhantomData",
"Ordering",
"Self",
"()",
"HashMap",
"HashSet",
"BTreeMap",
"BTreeSet",
"LinkedList",
"VecDeque",
"BinaryHeap",
"Path",
"PathBuf",
"Duration",
"Instant",
"SystemTime",
"Error",
"fmt",
"ops",
"cmp",
"iter",
"slice",
"cell",
"sync",
"collections",
"convert",
"marker",
"mem",
"ptr",
"hint",
"borrow",
"hash",
"default",
"num",
"array",
"range",
"std::",
"core::",
];
const SYSTEM_ROOTS: [&str; 3] = ["std::", "core::", "alloc::"];
const LOCAL_ROOTS: [&str; 3] = ["crate", "super", "self"];
fn is_system(path: &str) -> bool {
SYSTEM_ROOTS.iter().any(|p| path.starts_with(p))
}
pub fn collect_uses(exports: &[ExportItem], sources: &[String]) -> (Vec<String>, Vec<String>) {
let primitives: HashSet<&str> = PRIMITIVES.iter().copied().collect();
let local_names: HashSet<String> = exports.iter().map(|e| e.name.clone()).collect();
let mut import_paths: Vec<String> = Vec::new(); let mut system_imports: HashSet<String> = HashSet::new();
let mut type_imports: HashSet<String> = HashSet::new();
for line in sources.iter().flat_map(|src| src.lines()) {
let t = line.trim();
if !t.starts_with("use ") {
continue;
}
let path_part = t["use ".len()..].trim_end_matches(';').trim();
if path_part.is_empty() {
continue;
}
if path_part.contains('{') && !path_part.contains('}') {
continue;
}
if is_system(path_part) {
system_imports.insert(path_part.to_string());
} else {
type_imports.insert(path_part.to_string());
}
import_paths.push(path_part.to_string());
}
fn resolve_name(
name: &str,
import_paths: &[String],
local_names: &HashSet<String>,
primitives: &HashSet<&str>,
) -> Option<String> {
if primitives.contains(name) {
return None;
}
if is_system(name) {
return None;
}
if local_names.contains(name) {
return None;
}
let mut best: Option<(usize, String)> = None;
for p in import_paths {
let last = p.rsplit("::").next().unwrap_or(p);
if last == name || last == "*" {
let len = p.len();
if best.as_ref().map(|(l, _)| len < *l).unwrap_or(true) {
best = Some((len, p.clone()));
}
}
}
best.map(|(_, p)| p)
}
for item in exports {
let body = &item.source;
for m in body.match_indices("::") {
let start = m.0;
let slice = &body[start..];
let mut end = 0;
let mut depth: i32 = 0;
for (i, ch) in slice.char_indices() {
match ch {
'<' => depth += 1,
'>' => depth -= 1,
' ' | ',' | ';' | '{' | '}' | '(' | ')' | ':' | '[' | ']' | '.'
if depth == 0 =>
{
end = i;
break;
}
_ => {}
}
if depth < 0 {
end = i;
break;
}
}
let path = slice[..end].trim().trim_end_matches("::");
if path.is_empty() {
continue;
}
let first = path
.trim_start_matches(':')
.split("::")
.next()
.unwrap_or("");
if is_system(path) {
system_imports.insert(path.to_string());
} else if first == "yog_api" || LOCAL_ROOTS.contains(&first) {
type_imports.insert(path.to_string());
}
}
let re_check: Vec<&str> = body
.split(|c: char| !c.is_alphanumeric() && c != '_')
.filter(|s| !s.is_empty())
.collect();
for tok in re_check {
if tok
.chars()
.next()
.map(|c| c.is_uppercase())
.unwrap_or(false)
&& let Some(p) = resolve_name(tok, &import_paths, &local_names, &primitives)
{
if is_system(&p) {
system_imports.insert(p);
} else {
type_imports.insert(p);
}
}
}
}
fn normalize(vec: &mut Vec<String>) {
let mut groups: std::collections::BTreeMap<String, std::collections::BTreeSet<String>> =
Default::default();
let mut nested: Vec<String> = Vec::new();
for p in vec.drain(..) {
let p = p.trim().to_string();
if let Some((prefix, rest)) = p.split_once("::{") {
let inside = rest.strip_suffix('}').unwrap_or(rest);
if inside.contains('{') {
nested.push(p);
continue;
}
let set = groups.entry(prefix.to_string()).or_default();
set.extend(
inside
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(str::to_string),
);
} else if let Some((prefix, item)) = p.rsplit_once("::") {
groups
.entry(prefix.to_string())
.or_default()
.insert(item.to_string());
} else {
nested.push(p);
}
}
let mut keep: Vec<String> = groups
.into_iter()
.map(|(prefix, items)| {
if items.len() == 1 {
format!("{prefix}::{}", items.into_iter().next().unwrap())
} else {
let items: Vec<String> = items.into_iter().collect();
format!("{prefix}::{{{}}}", items.join(", "))
}
})
.collect();
nested.sort();
nested.dedup();
keep.extend(nested);
keep.sort();
*vec = keep;
}
let mut system_vec: Vec<String> = system_imports.into_iter().collect();
let mut type_vec: Vec<String> = type_imports.into_iter().collect();
normalize(&mut system_vec);
normalize(&mut type_vec);
(system_vec, type_vec)
}
pub fn strip_derive_attrs(source: &str) -> String {
source
.lines()
.filter(|line| !line.trim().starts_with("#[derive("))
.collect::<Vec<_>>()
.join("\n")
}
fn fn_signature(source: &str) -> (Vec<(String, String)>, String) {
use syn::spanned::Spanned;
let Ok(f) = syn::parse_str::<syn::ItemFn>(source) else {
return (Vec::new(), "()".into());
};
let text = |s: proc_macro2::Span| source[s.byte_range()].to_string();
let params = f
.sig
.inputs
.iter()
.filter_map(|a| match a {
syn::FnArg::Typed(t) => Some((text(t.pat.span()), text(t.ty.span()))),
syn::FnArg::Receiver(_) => None,
})
.collect();
let output = match &f.sig.output {
syn::ReturnType::Default => "()".into(),
syn::ReturnType::Type(_, ty) => text(ty.span()),
};
(params, output)
}
pub fn generate_fn_wrapper(mod_id: &str, name: &str, source: &str) -> String {
let (mut typed_params, output_type) = fn_signature(source);
if typed_params
.first()
.map(|(_, ty)| ty.replace(' ', "").ends_with("Registry"))
.unwrap_or(false)
{
typed_params.remove(0);
}
let arg_names: Vec<String> = typed_params
.iter()
.enumerate()
.map(|(i, (pat, _))| {
let name = pat.trim().trim_start_matches("mut ").trim();
let plain = !name.is_empty()
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
&& !name.starts_with(|c: char| c.is_ascii_digit());
if plain { name.to_string() } else { format!("__yog_arg{i}") }
})
.collect();
let data_types: Vec<&str> = typed_params.iter().map(|(_, ty)| ty.as_str()).collect();
let params_sig = arg_names
.iter()
.zip(data_types.iter())
.map(|(n, t)| format!("{n}: {t}"))
.collect::<Vec<_>>()
.join(", ");
let tuple_expr = match arg_names.len() {
0 => String::new(),
1 => arg_names[0].clone(),
_ => format!("({})", arg_names.join(", ")),
};
let slot_name = format!("__YOG_SLOT_{}", name.to_uppercase());
let bind_name = format!("__yog_bind_{}", name);
let output_type_fmt = output_type.replace('{', "{{").replace('}', "}}");
let params_sig_fmt = params_sig.replace('{', "{{").replace('}', "}}");
let slot_name_fmt = slot_name.replace('{', "{{").replace('}', "}}");
let bind_name_fmt = bind_name.replace('{', "{{").replace('}', "}}");
let wrapper_ty = "::yog_api::YogExportFn".to_string();
let serialize_block = if tuple_expr.is_empty() {
"let input_bytes: Vec<u8> = Vec::new();".to_string()
} else {
format!(
"let aligned = ::yog_api::rkyv::to_bytes::<::yog_api::rkyv::rancor::Error>(\
&{tuple_expr}\
).unwrap_or_default();\n \
let input_bytes: Vec<u8> = aligned.to_vec();"
)
};
format!(
r#"#[allow(non_snake_case)]
pub fn {name}({params_sig_fmt}) -> {output_type_fmt} {{
{serialize_block}
let f = {slot_name_fmt}.get()
.expect(concat!("yog: export '", "{name}", "' not bound"));
let output = ::yog_api::__interop_call(*f, &input_bytes, "{name}");
::yog_api::rkyv::from_bytes::<{output_type_fmt}, ::yog_api::rkyv::rancor::Error>(&output)
.expect(concat!("yog: the result of export '", "{name}", "' didn't decode"))
}}
static {slot_name_fmt}: ::std::sync::OnceLock<{wrapper_ty}> = ::std::sync::OnceLock::new();
#[doc(hidden)]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn {bind_name_fmt}(ptr: *const ::std::os::raw::c_void) {{
let f: {wrapper_ty} = unsafe {{ ::std::mem::transmute(ptr) }};
{slot_name_fmt}.set(f).ok();
}}
// Queues the binding when the consuming mod's library loads; `export_mod!`
// binds it once `{mod_id}` has exported `{name}`.
#[::yog_api::ctor::ctor(unsafe, crate_path = ::yog_api::ctor)]
#[doc(hidden)]
fn __yog_import_ctor_{name}() {{
::yog_api::__yog_import_registry().lock().unwrap().push(::yog_api::YogImportEntry {{
mod_id: "{mod_id}",
symbol: "{name}",
bind_fn: {bind_name_fmt} as *const () as usize,
}});
}}
"#
)
}
pub fn collect_rs_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
let mut out = Vec::new();
for entry in std::fs::read_dir(dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
if path.is_dir() {
out.extend(collect_rs_files(&path)?);
} else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
out.push(path);
}
}
Ok(out)
}
pub struct CrateMeta<'a> {
pub name: &'a str,
pub version: &'a str,
pub edition: &'a str,
pub authors: &'a str,
pub license: &'a str,
pub mod_id: &'a str,
pub yog_api: &'a str,
pub rkyv: &'a str,
}
pub fn cargo_toml(m: &CrateMeta) -> String {
let maybe_authors = if m.authors.is_empty() {
String::new()
} else {
format!("authors = [\"{}\"]\n", m.authors.replace(", ", "\", \""))
};
format!(
r#"[package]
name = "{name}"
version = "{version}"
edition = "{edition}"
{maybe_authors}license = "{license}"
description = "Exports from the {mod_id} Yog mod — generated by `yog publish exports`."
[dependencies]
yog-api = "{yog_api}"
rkyv = "{rkyv}"
[lib]
crate-type = ["cdylib", "lib"]
"#,
name = m.name,
version = m.version,
edition = m.edition,
license = m.license,
mod_id = m.mod_id,
yog_api = m.yog_api,
rkyv = m.rkyv,
)
}
pub fn lib_rs(
mod_id: &str,
version: &str,
exports: &[ExportItem],
system_uses: &[String],
type_uses: &[String],
) -> String {
let mut lib_rs = format!(
"//! Auto-generated exports from `{mod_id}` v{version}.\n\
//! DO NOT EDIT — generated by `yog publish exports`.\n\n\
#![allow(unused_imports)]\n\n"
);
if !system_uses.is_empty() {
lib_rs.push_str("// System dependencies\n");
for u in system_uses {
let _ = writeln!(lib_rs, "use {u};");
}
lib_rs.push('\n');
}
let exported_names: HashSet<&str> = exports.iter().map(|e| e.name.as_str()).collect();
if !type_uses.is_empty() {
lib_rs.push_str("// Type dependencies\n");
for u in type_uses {
let first_part = u.split("::").next().unwrap_or("");
if LOCAL_ROOTS.contains(&first_part) {
continue;
}
let last_part = u.rsplit("::").next().unwrap_or(u);
if exported_names.contains(last_part) {
continue;
}
let _ = writeln!(lib_rs, "use {u};");
}
lib_rs.push('\n');
}
for item in exports {
match item.kind {
ExportKind::Struct | ExportKind::Enum => {
let _ = writeln!(
lib_rs,
"#[derive(::yog_api::rkyv::Archive, ::yog_api::rkyv::Serialize, ::yog_api::rkyv::Deserialize, Debug, Clone, PartialEq)]\n{}",
strip_derive_attrs(&item.source)
);
}
ExportKind::Fn => lib_rs.push_str(&generate_fn_wrapper(mod_id, &item.name, &item.source)),
}
}
lib_rs
}
#[cfg(test)]
mod tests {
use super::*;
const SRC: &str = r#"
use yog_api::prelude::*;
#[yog_export]
pub struct Pipe { pub id: u32 }
#[yog_export]
pub enum Kind { Item, Fluid }
/// Not exported.
pub fn helper() {}
#[yog_export]
pub fn send(
registry: &mut Registry,
pipe: Pipe,
amount: u32,
) -> Option<String> {
None
}
#[yog_export]
pub fn count() -> u64 { 0 }
"#;
#[test]
fn scans_every_exported_item_and_nothing_else() {
let items = scan(SRC);
let names: Vec<(&str, ExportKind)> = items.iter().map(|i| (i.name.as_str(), i.kind)).collect();
assert_eq!(names, [("Pipe", ExportKind::Struct), ("Kind", ExportKind::Enum), ("send", ExportKind::Fn), ("count", ExportKind::Fn)]);
assert!(!items.iter().any(|i| i.source.contains("#[yog_export]")), "the attribute is stripped");
assert!(items[2].source.contains("amount: u32"), "a wrapped signature is whole");
}
#[test]
fn wrappers_call_through_one_helper_and_drop_the_registry() {
let items = scan(SRC);
let w = generate_fn_wrapper("yog-pipes", "send", &items[2].source);
assert!(w.contains("pub fn send(pipe: Pipe, amount: u32) -> Option<String>"), "callers see the export's names: {w}");
assert!(w.contains("::yog_api::__interop_call("), "{w}");
assert!(w.contains("static __YOG_SLOT_SEND"), "{w}");
let none = generate_fn_wrapper("yog-pipes", "count", &items[3].source);
assert!(none.contains("pub fn count() -> u64"), "{none}");
}
#[test]
fn a_doc_comment_with_parentheses_keeps_the_parameters() {
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";
let item = &scan(src)[0];
assert!(item.source.starts_with("/// Drives"), "{}", item.source);
let w = generate_fn_wrapper("yog-pipes", "emit", &item.source);
assert!(w.contains("pub fn emit(capability: String, pos: (i32, i32), levels: HashMap<String, u8>) -> Result<(), String>"), "{w}");
assert!(w.contains("&(capability, pos, levels)"), "{w}");
}
#[test]
fn braces_in_strings_and_comments_dont_end_an_item() {
let src = r#"
mod inner {
/// Opens `{` — and a doc comment can say so.
#[yog_export]
pub fn open(s: String) -> String {
// } not the end
format!("{{{s}")
}
}
#[yog_export]
pub struct After { pub x: u8 }
"#;
let items = scan(src);
let names: Vec<&str> = items.iter().map(|i| i.name.as_str()).collect();
assert_eq!(names, ["open", "After"]);
assert!(items[0].source.starts_with("/// Opens"), "{}", items[0].source);
assert!(items[0].source.trim_end().ends_with('}') && items[0].source.contains("format!(\"{{{s}\")"), "{}", items[0].source);
assert!(!items[0].source.contains("yog_export"));
}
}