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,
21 Enum,
22 Fn,
23}
24
25#[derive(Debug)]
27pub struct ExportItem {
28 pub kind: ExportKind,
29 pub name: String,
30 pub source: String,
32}
33
34pub fn scan(src: &str) -> Vec<ExportItem> {
39 let Ok(file) = syn::parse_file(src) else {
40 return Vec::new();
41 };
42 let mut out = Vec::new();
43 collect(&file.items, src, &mut out);
44 out
45}
46
47fn collect(items: &[syn::Item], src: &str, out: &mut Vec<ExportItem>) {
48 use syn::spanned::Spanned;
49 for item in items {
50 let (attrs, vis, kind, name) = match item {
51 syn::Item::Struct(s) => (&s.attrs, &s.vis, ExportKind::Struct, s.ident.to_string()),
52 syn::Item::Enum(e) => (&e.attrs, &e.vis, ExportKind::Enum, e.ident.to_string()),
53 syn::Item::Fn(f) => (&f.attrs, &f.vis, ExportKind::Fn, f.sig.ident.to_string()),
54 syn::Item::Mod(m) => {
55 if let Some((_, inner)) = &m.content {
56 collect(inner, src, out);
57 }
58 continue;
59 }
60 _ => continue,
61 };
62 let is_export = |a: &syn::Attribute| {
63 a.path()
64 .segments
65 .last()
66 .is_some_and(|s| s.ident == "yog_export")
67 };
68 if !attrs.iter().any(is_export) || !matches!(vis, syn::Visibility::Public(_)) {
69 continue;
70 }
71 let whole = item.span().byte_range();
72 let mut text = String::new();
74 let mut at = whole.start;
75 for a in attrs.iter().filter(|a| is_export(a)) {
76 let r = a.span().byte_range();
77 text.push_str(&src[at..r.start]);
78 at = r.end;
79 let rest = &src[at..whole.end];
80 let blank = rest.len() - rest.trim_start_matches([' ', '\t']).len();
81 if rest[blank..].starts_with('\n') {
82 at += blank + 1;
83 }
84 }
85 text.push_str(&src[at..whole.end]);
86 let source = format!("{}\n", text.trim_start());
88 out.push(ExportItem { kind, name, source });
89 }
90}
91
92const PRIMITIVES: &[&str] = &[
95 "bool",
96 "u8",
97 "u16",
98 "u32",
99 "u64",
100 "u128",
101 "usize",
102 "i8",
103 "i16",
104 "i32",
105 "i64",
106 "i128",
107 "isize",
108 "f32",
109 "f64",
110 "char",
111 "str",
112 "String",
113 "Vec",
114 "Option",
115 "Result",
116 "Box",
117 "Rc",
118 "Arc",
119 "Cow",
120 "PhantomData",
121 "Ordering",
122 "Self",
123 "()",
124 "HashMap",
125 "HashSet",
126 "BTreeMap",
127 "BTreeSet",
128 "LinkedList",
129 "VecDeque",
130 "BinaryHeap",
131 "Path",
132 "PathBuf",
133 "Duration",
134 "Instant",
135 "SystemTime",
136 "Error",
137 "fmt",
138 "ops",
139 "cmp",
140 "iter",
141 "slice",
142 "cell",
143 "sync",
144 "collections",
145 "convert",
146 "marker",
147 "mem",
148 "ptr",
149 "hint",
150 "borrow",
151 "hash",
152 "default",
153 "num",
154 "array",
155 "range",
156 "std::",
157 "core::",
158];
159
160const SYSTEM_ROOTS: [&str; 3] = ["std::", "core::", "alloc::"];
162
163const LOCAL_ROOTS: [&str; 3] = ["crate", "super", "self"];
165
166fn is_system(path: &str) -> bool {
167 SYSTEM_ROOTS.iter().any(|p| path.starts_with(p))
168}
169
170const PATH_SEP: &str = "::";
172const USE: &str = "use ";
174const GLOB: &str = "*";
176
177#[derive(Default)]
179struct Imports {
180 system: HashSet<String>,
181 types: HashSet<String>,
182}
183
184impl Imports {
185 fn add(&mut self, path: String) {
186 if is_system(&path) {
187 self.system.insert(path);
188 } else {
189 self.types.insert(path);
190 }
191 }
192}
193
194fn mod_uses(sources: &[String]) -> Vec<String> {
197 sources
198 .iter()
199 .flat_map(|src| src.lines())
200 .filter_map(|line| line.trim().strip_prefix(USE))
201 .map(|p| p.trim_end_matches(';').trim())
202 .filter(|p| !p.is_empty() && !(p.contains('{') && !p.contains('}')))
203 .map(str::to_string)
204 .collect()
205}
206
207struct Resolver<'a> {
209 uses: &'a [String],
210 local: HashSet<&'a str>,
212}
213
214impl Resolver<'_> {
215 fn resolve(&self, name: &str) -> Option<String> {
219 if PRIMITIVES.contains(&name) || is_system(name) || self.local.contains(name) {
220 return None;
221 }
222 self.uses
223 .iter()
224 .filter(
225 |p| matches!(p.rsplit(PATH_SEP).next(), Some(last) if last == name || last == GLOB),
226 )
227 .min_by_key(|p| p.len())
228 .cloned()
229 }
230}
231
232fn path_at(slice: &str) -> &str {
235 let mut depth: i32 = 0;
236 let mut end = 0;
237 for (i, ch) in slice.char_indices() {
238 match ch {
239 '<' => depth += 1,
240 '>' => depth -= 1,
241 ' ' | ',' | ';' | '{' | '}' | '(' | ')' | ':' | '[' | ']' | '.' if depth == 0 => {
242 end = i;
243 break;
244 }
245 _ => {}
246 }
247 if depth < 0 {
248 end = i;
249 break;
250 }
251 }
252 slice[..end].trim().trim_end_matches(PATH_SEP)
253}
254
255fn written_paths(body: &str, imports: &mut Imports) {
257 let api = yog_ids::rust_name(yog_ids::rust_crate::API);
258 for (start, _) in body.match_indices(PATH_SEP) {
259 let path = path_at(&body[start..]);
260 if path.is_empty() {
261 continue;
262 }
263 let first = path
264 .trim_start_matches(':')
265 .split(PATH_SEP)
266 .next()
267 .unwrap_or("");
268 if is_system(path) || first == api || LOCAL_ROOTS.contains(&first) {
269 imports.add(path.to_string());
270 }
271 }
272}
273
274fn bare_types(body: &str, resolver: &Resolver, imports: &mut Imports) {
276 let words = body
277 .split(|c: char| !c.is_alphanumeric() && c != '_')
278 .filter(|s| !s.is_empty());
279 for word in words {
280 if word.starts_with(char::is_uppercase)
281 && let Some(p) = resolver.resolve(word)
282 {
283 imports.add(p);
284 }
285 }
286}
287
288pub fn collect_uses(exports: &[ExportItem], sources: &[String]) -> (Vec<String>, Vec<String>) {
301 let uses = mod_uses(sources);
302 let mut imports = Imports::default();
303 for u in &uses {
304 imports.add(u.clone());
305 }
306 let resolver = Resolver {
307 uses: &uses,
308 local: exports.iter().map(|e| e.name.as_str()).collect(),
309 };
310 for item in exports {
311 written_paths(&item.source, &mut imports);
312 bare_types(&item.source, &resolver, &mut imports);
313 }
314 (normalize(imports.system), normalize(imports.types))
315}
316
317fn normalize(paths: HashSet<String>) -> Vec<String> {
320 let mut groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
321 let mut nested: Vec<String> = Vec::new();
322 for p in paths {
323 let p = p.trim().to_string();
324 if let Some((prefix, rest)) = p.split_once("::{") {
325 let inside = rest.strip_suffix('}').unwrap_or(rest);
326 if inside.contains('{') {
327 nested.push(p);
328 continue;
329 }
330 let names = inside
331 .split(',')
332 .map(str::trim)
333 .filter(|s| !s.is_empty())
334 .map(str::to_string);
335 groups.entry(prefix.to_string()).or_default().extend(names);
336 } else if let Some((prefix, item)) = p.rsplit_once(PATH_SEP) {
337 groups
338 .entry(prefix.to_string())
339 .or_default()
340 .insert(item.to_string());
341 } else {
342 nested.push(p);
343 }
344 }
345 let mut keep: Vec<String> = groups
346 .into_iter()
347 .map(|(prefix, names)| grouped(&prefix, names))
348 .collect();
349 nested.sort();
350 nested.dedup();
351 keep.extend(nested);
352 keep.sort();
353 keep
354}
355
356fn grouped(prefix: &str, names: BTreeSet<String>) -> String {
358 let names: Vec<String> = names.into_iter().collect();
359 match names.as_slice() {
360 [one] => format!("{prefix}{PATH_SEP}{one}"),
361 _ => format!("{prefix}{PATH_SEP}{{{}}}", names.join(", ")),
362 }
363}
364
365pub fn strip_derive_attrs(source: &str) -> String {
368 source
369 .lines()
370 .filter(|line| !line.trim().starts_with("#[derive("))
371 .collect::<Vec<_>>()
372 .join("\n")
373}
374
375pub fn import_signature(source: &str) -> Option<String> {
379 use syn::spanned::Spanned;
380 let f = syn::parse_str::<syn::ItemFn>(source).ok()?;
381 let sig = source.get(f.sig.span().byte_range())?;
382 Some(format!(
384 "pub {};",
385 sig.split_whitespace().collect::<Vec<_>>().join(" ")
386 ))
387}
388
389pub fn collect_rs_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
392 let mut out = Vec::new();
393 for entry in std::fs::read_dir(dir).map_err(|e| e.to_string())? {
394 let entry = entry.map_err(|e| e.to_string())?;
395 let path = entry.path();
396 if path.is_dir() {
397 out.extend(collect_rs_files(&path)?);
398 } else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
399 out.push(path);
400 }
401 }
402 Ok(out)
403}
404
405pub struct CrateMeta<'a> {
407 pub name: &'a str,
408 pub version: &'a str,
409 pub edition: &'a str,
410 pub authors: &'a str,
412 pub license: &'a str,
413 pub mod_id: &'a str,
414 pub yog_api: &'a str,
415}
416
417pub fn cargo_toml(m: &CrateMeta) -> String {
419 let maybe_authors = if m.authors.is_empty() {
420 String::new()
421 } else {
422 format!("authors = [\"{}\"]\n", m.authors.replace(", ", "\", \""))
423 };
424 format!(
425 r#"[package]
426name = "{name}"
427version = "{version}"
428edition = "{edition}"
429{maybe_authors}license = "{license}"
430description = "Exports from the {mod_id} Yog mod — generated by `yog publish exports`."
431
432[dependencies]
433yog-api = "{yog_api}"
434
435[lib]
436crate-type = ["cdylib", "lib"]
437"#,
438 name = m.name,
439 version = m.version,
440 edition = m.edition,
441 license = m.license,
442 mod_id = m.mod_id,
443 yog_api = m.yog_api,
444 )
445}
446
447pub fn lib_rs(
451 mod_id: &str,
452 version: &str,
453 exports: &[ExportItem],
454 system_uses: &[String],
455 type_uses: &[String],
456) -> String {
457 let mut lib_rs = format!(
458 "//! Auto-generated exports from `{mod_id}` v{version}.\n\
459 //! DO NOT EDIT — generated by `yog publish exports`.\n\n\
460 #![allow(unused_imports)]\n\n"
461 );
462
463 if !system_uses.is_empty() {
465 lib_rs.push_str("// System dependencies\n");
466 for u in system_uses {
467 let _ = writeln!(lib_rs, "use {u};");
468 }
469 lib_rs.push('\n');
470 }
471
472 let exported_names: HashSet<&str> = exports.iter().map(|e| e.name.as_str()).collect();
475 if !type_uses.is_empty() {
476 lib_rs.push_str("// Type dependencies\n");
477 for u in type_uses {
478 let first_part = u.split("::").next().unwrap_or("");
482 if LOCAL_ROOTS.contains(&first_part) {
483 continue;
484 }
485 let last_part = u.rsplit("::").next().unwrap_or(u);
486 if exported_names.contains(last_part) {
487 continue;
488 }
489 let _ = writeln!(lib_rs, "use {u};");
490 }
491 lib_rs.push('\n');
492 }
493
494 for item in exports.iter().filter(|i| i.kind != ExportKind::Fn) {
496 let _ = writeln!(
497 lib_rs,
498 "#[::yog_api::yog_export]\n#[derive(Debug, Clone, PartialEq)]\n{}\n",
499 strip_derive_attrs(&item.source)
500 );
501 }
502 let fns: Vec<String> = exports
504 .iter()
505 .filter(|i| i.kind == ExportKind::Fn)
506 .filter_map(|i| import_signature(&i.source))
507 .collect();
508 if !fns.is_empty() {
509 let module = yog_ids::rust_name(mod_id);
510 let _ = writeln!(
511 lib_rs,
512 "::yog_api::import! {{\n from {mod_id:?} {{\n {}\n }}\n}}\npub use {module}::*;",
513 fns.join("\n ")
514 );
515 }
516 lib_rs
517}
518
519#[cfg(test)]
520mod tests {
521 use super::*;
522
523 const SRC: &str = r#"
524use yog_api::prelude::*;
525
526#[yog_export]
527pub struct Pipe { pub id: u32 }
528
529#[yog_export]
530pub enum Kind { Item, Fluid }
531
532/// Not exported.
533pub fn helper() {}
534
535#[yog_export]
536pub fn send(
537 registry: &mut Registry,
538 pipe: Pipe,
539 amount: u32,
540) -> Option<String> {
541 None
542}
543
544#[yog_export]
545pub fn count() -> u64 { 0 }
546"#;
547
548 #[test]
549 fn scans_every_exported_item_and_nothing_else() {
550 let items = scan(SRC);
551 let names: Vec<(&str, ExportKind)> =
552 items.iter().map(|i| (i.name.as_str(), i.kind)).collect();
553 assert_eq!(
554 names,
555 [
556 ("Pipe", ExportKind::Struct),
557 ("Kind", ExportKind::Enum),
558 ("send", ExportKind::Fn),
559 ("count", ExportKind::Fn)
560 ]
561 );
562 assert!(
563 !items.iter().any(|i| i.source.contains("#[yog_export]")),
564 "the attribute is stripped"
565 );
566 assert!(
567 items[2].source.contains("amount: u32"),
568 "a wrapped signature is whole"
569 );
570 }
571
572 #[test]
573 fn functions_become_import_items_and_types_exports() {
574 let items = scan(SRC);
575 let lib = lib_rs("yog-pipes", "1.0.0", &items, &[], &[]);
576 assert!(
577 lib.contains("::yog_api::import! {\n from \"yog-pipes\""),
578 "{lib}"
579 );
580 assert!(lib.contains("pub fn send( registry: &mut Registry, pipe: Pipe, amount: u32, ) -> Option<String>;"), "{lib}");
581 assert!(lib.contains("pub use yog_pipes::*;"), "{lib}");
582 assert!(
583 lib.contains(
584 "#[::yog_api::yog_export]\n#[derive(Debug, Clone, PartialEq)]\npub struct Pipe"
585 ),
586 "{lib}"
587 );
588 }
589
590 #[test]
591 fn a_doc_comment_with_parentheses_keeps_the_parameters() {
592 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";
593 let item = &scan(src)[0];
594 assert!(item.source.starts_with("/// Drives"), "{}", item.source);
595 let sig = import_signature(&item.source).expect("parses");
596 assert!(
597 sig.contains("capability: String, mut pos: (i32, i32), levels: HashMap<String, u8>,"),
598 "{sig}"
599 );
600 }
601
602 #[test]
603 fn braces_in_strings_and_comments_dont_end_an_item() {
604 let src = r#"
605mod inner {
606 /// Opens `{` — and a doc comment can say so.
607 #[yog_export]
608 pub fn open(s: String) -> String {
609 // } not the end
610 format!("{{{s}")
611 }
612}
613
614#[yog_export]
615pub struct After { pub x: u8 }
616"#;
617 let items = scan(src);
618 let names: Vec<&str> = items.iter().map(|i| i.name.as_str()).collect();
619 assert_eq!(names, ["open", "After"]);
620 assert!(
621 items[0].source.starts_with("/// Opens"),
622 "{}",
623 items[0].source
624 );
625 assert!(
626 items[0].source.trim_end().ends_with('}')
627 && items[0].source.contains("format!(\"{{{s}\")"),
628 "{}",
629 items[0].source
630 );
631 assert!(!items[0].source.contains("yog_export"));
632 }
633}